Compare commits

..

11 Commits

123 changed files with 5687 additions and 6514 deletions
-9
View File
@@ -75,15 +75,6 @@ When auto-applying an agent, inform the user:
## TIER 0: UNIVERSAL RULES (Always Active) ## TIER 0: UNIVERSAL RULES (Always Active)
### 🌍 Global User Rules (MANDATORY)
1. **Idioma**: Conversar e desenvolver sempre em **Português do Brasil**.
2. **Automação Terminais**: Executar comandos de terminal, leitura de arquivos e edições de forma automática. Comandos de desenvolvimento (build, install, lint, git, etc.) são sempre `SafeToAutoRun`.
3. **Memória & Monitoramento**: Após cada evolução ou atualização no app, é OBRIGATÓRIO:
- Atualizar a memória do app em `/root/Antigravity_Memory/knowledge/{AppName}.md`.
- Atualizar o status e credenciais no `app.py` do Cronos Dashboard.
- Realizar `git commit`, `git push` e executar o `update.sh` do Cronos para deploy automático.
### 🌐 Language Handling ### 🌐 Language Handling
When user's prompt is NOT in English: When user's prompt is NOT in English:
+4 -3
View File
@@ -1,6 +1,7 @@
node_modules node_modules
dist dist
.git .git
.gitignore .agent
*.md .antigravity
.env* uploads
*.pdf
+4 -10
View File
@@ -1,15 +1,9 @@
FROM node:22-alpine FROM node:22-slim
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY . . COPY . .
# Build the frontend
RUN npm run build RUN npm run build
ENV PORT=3000
EXPOSE 3000 EXPOSE 3000
CMD ["node", "dist/server/src/server/index.js"]
CMD ["npm", "run", "start"]
+2 -1
View File
@@ -1 +1,2 @@
Tue Mar 31 10:45:40 UTC 2026 Force Refresh Vercel - Timestamp: 2026-01-25 13:55
Commit Hash Target: 30f8b5c
-27
View File
@@ -1,27 +0,0 @@
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
});
}
}
-26
View File
@@ -1,26 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import type { VercelRequest, VercelResponse } from '@vercel/node';
import app from '../src/server/app.js';
import { connectDB } from '../src/server/config/database.js';
export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
console.log('--- API CALL:', req.url);
// Conecta ao Banco de Dados (Supabase/Postgres)
await connectDB();
// Passa o controle para o Express
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
});
}
}
-10
View File
@@ -1,10 +0,0 @@
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'
});
}
-57
View File
@@ -1,57 +0,0 @@
TRUNCATE gpi.projects, gpi.technical_data_sheets, gpi.painting_schemes, gpi.parts, gpi.stock_items, gpi.notifications, gpi.yield_studies, gpi.geometry_types, gpi.messages, gpi.stock_audit_logs, gpi.system_settings, gpi.stored_files CASCADE;
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('8d3d82a6-584b-4a3f-9202-72b4d6cac8f1', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Chaparia comum', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('f994e015-dece-4d57-a112-b4813ba0f7b1', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Chapas de pisos (>0,5m²)', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('e19abd8b-f1b5-4eb2-a232-db38a5c2a306', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Peças diversas (outras)', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('cc789669-5801-4885-be69-e981adf73682', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Guarda-corpo/escada', 50, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('9c1b96c3-f41d-4062-afd9-ba600ccbd441', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Vigas leves', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('37fc80e8-7c67-44cf-9dcf-9571bbc936c5', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Calhas', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('d464cab2-2053-4b22-a770-6db47c40e508', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Telhas', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('654f690b-dd45-4d89-8d05-81b0a3963989', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Vigas médias', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('8e4a569d-2a03-4740-bd2a-b0077a03ef4f', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Vigas pesadas', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('41aaea81-3880-43fe-8455-3cf5e8ecee74', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Cantoneiras', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('97f82995-bfa9-4af2-8b16-45664d180249', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Tubulações (ret/red) <100mm', 20, NOW());
INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES ('ff89d5bd-5b16-4462-8d5e-7ff79062b510', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Tubulações (ret/red) >100mm', 20, NOW());
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('900da2be-c901-453d-9dff-7aff16dd9a35', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Revran PHZ 528', 'RENNER', NULL, 'Epóxi (N-2630)', NULL, NULL, 82, NULL, NULL, 8.2, 122, 183, 100, 150, '420.0000', '100,0 : 101,0', '1,0 : 1,0', 100, 820, 10, '', '2026-01-24T11:26:09.181Z', '2026-02-06T18:55:55.697Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('d5392776-e6a4-41f9-8c75-65660563c978', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Revran DST QD 721', 'RENNER', NULL, 'Epóxi dupla função', NULL, NULL, 80, NULL, NULL, 6.67, 150, 312, 120, 250, '420.0000', '100,0 : 88.0000', '1,0 : 1,0', 120, 800, 10, '', '2026-01-24T11:26:09.340Z', '2026-02-06T18:54:37.426Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('8e099c07-7ad4-4d94-8846-cd0c6dd08e06', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Oxibar DFC 707', 'RENNER', NULL, 'Epóxi dupla função', NULL, NULL, 82, NULL, NULL, 8.2, 121, 244, 100, 200, '420.0000', '100,0 : 15,0', '4,0 : 1,0', 100, 820, 10, '', '2026-01-30T10:53:06.720Z', '2026-02-06T18:54:01.241Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('3c0651fc-93e9-436c-a6b8-72c1d4c848a9', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Rethane FHB 658', 'RENNER', NULL, 'Poliuretano (PU)', NULL, NULL, 70, NULL, NULL, 11.7, 60, 125, 86, 178, '440.0000', '100,0 : 18,0', '4,0 : 1,0', 60, 700, 10, '', '2026-01-30T14:36:46.654Z', '2026-02-06T18:47:20.843Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('be6eaa90-03ff-4028-bae8-2928fbdfb254', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Revran DST PLUS 727', 'RENNER', NULL, 'Epóxi dupla função', NULL, NULL, 80, NULL, NULL, 6.7, 150, 188, 120, 150, '420.0000', '100,0 : 95,0', '1,0 : 1,0', 120, 800, 10, '', '2026-02-06T18:50:27.668Z', '2026-02-06T18:52:53.633Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('732f2322-6fab-4e1e-b10b-c5928edc2351', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Diluente para Epoxi (Revran)', 'RENNER', '420.0000', 'THINNER', 30, 'diluente para epoxi da linha Revran e demais que utilizam o mesmo codigo', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2026-02-12T14:33:21.603Z', '2026-02-12T14:33:21.603Z');
INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES ('c65de128-fa1e-4a93-bd3c-780d6de39199', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Diluente PU e Esmalte Sintético (Rethanne)', 'RENNER', '440.0000', 'THINNER', 10, 'diluente para tintas da linha Rethanne e demais que utilizam o mesmo codigo', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '2026-02-12T14:35:04.836Z', '2026-02-12T14:54:04.925Z');
INSERT INTO gpi.projects (id, organization_id, name, client, start_date, end_date, environment, technician, weight_kg, status, created_at, updated_at) VALUES ('1c7ed38a-a7b8-4ab4-965c-b74cbf9a6291', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'B121 - Residência Bia', 'FairBanks', '2025-12-01T00:00:00.000Z', '2026-03-31T00:00:00.000Z', 'C3', 'Eng. Baldon', 32165, 'active', '2026-02-09T10:21:58.928Z', '2026-02-09T10:24:35.727Z');
INSERT INTO gpi.projects (id, organization_id, name, client, start_date, end_date, environment, technician, weight_kg, status, created_at, updated_at) VALUES ('28ac813e-0d9d-4ef3-8b4e-ba34224f2e55', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'B129 - Rampa Toyota', 'Loja Toyota', '2026-02-11T00:00:00.000Z', '2026-04-15T00:00:00.000Z', 'C3', 'Eng. Baldon', 12430, 'active', '2026-02-09T10:36:24.278Z', '2026-02-25T17:54:11.346Z');
INSERT INTO gpi.projects (id, organization_id, name, client, start_date, end_date, environment, technician, weight_kg, status, created_at, updated_at) VALUES ('30db3f02-8631-4c11-bba9-74b40c6ebb1c', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'B128 - Cobertura Bridgestone', 'BRIDGESTONE', '2026-01-15T00:00:00.000Z', '2026-03-31T00:00:00.000Z', 'C4', 'Eng. Baldon', 6880, 'active', '2026-02-09T10:40:53.554Z', '2026-02-09T10:40:53.554Z');
INSERT INTO gpi.painting_schemes (id, organization_id, project_id, name, type, coat, solids_volume, yield_theoretical, eps_min, eps_max, dilution, manufacturer, color, paint_consumption, thinner_consumption, paint_id, thinner_id, color_hex, thinner_symbol, notes, created_at, updated_at) VALUES ('5ef2647b-4edd-48cf-9ae3-4c7e89d2ed4d', 'e47e6210-4879-4e5b-bf21-9285d2713123', '1c7ed38a-a7b8-4ab4-965c-b74cbf9a6291', 'Revran DST QD 721', 'epoxy', 'Primer', 80, 6.67, 100, 140, 10, 'RENNER', 'Cinza N6.5', 12, 15, '6974ac5112e9ddef6c122b81', NULL, '#dedede', '420.0000', '', NOW(), NOW());
INSERT INTO gpi.painting_schemes (id, organization_id, project_id, name, type, coat, solids_volume, yield_theoretical, eps_min, eps_max, dilution, manufacturer, color, paint_consumption, thinner_consumption, paint_id, thinner_id, color_hex, thinner_symbol, notes, created_at, updated_at) VALUES ('98efc99e-300c-4e3d-bb93-bcd7e27f25f0', 'e47e6210-4879-4e5b-bf21-9285d2713123', '28ac813e-0d9d-4ef3-8b4e-ba34224f2e55', 'Revran PHZ 528', 'epoxy', 'Primer', 82, 8.2, 100, 140, 15, 'RENNER', 'Cinza Grafite', 12, 20, '6974ac5112e9ddef6c122b7e', NULL, '#6b6b6b', '420.0000', '', NOW(), NOW());
INSERT INTO gpi.painting_schemes (id, organization_id, project_id, name, type, coat, solids_volume, yield_theoretical, eps_min, eps_max, dilution, manufacturer, color, paint_consumption, thinner_consumption, paint_id, thinner_id, color_hex, thinner_symbol, notes, created_at, updated_at) VALUES ('d49a9fae-1dc2-42ef-9d12-200d97207e59', 'e47e6210-4879-4e5b-bf21-9285d2713123', '30db3f02-8631-4c11-bba9-74b40c6ebb1c', 'Revran PHZ 528', 'epoxy', 'Primer', 82, 8.2, 100, 120, 10, 'RENNER', 'Cinza N6.5', 12, 15, '6974ac5112e9ddef6c122b7e', NULL, '#dedede', '420.0000', '', NOW(), NOW());
INSERT INTO gpi.painting_schemes (id, organization_id, project_id, name, type, coat, solids_volume, yield_theoretical, eps_min, eps_max, dilution, manufacturer, color, paint_consumption, thinner_consumption, paint_id, thinner_id, color_hex, thinner_symbol, notes, created_at, updated_at) VALUES ('6233be81-1de2-4a6f-9589-60db5b5dfc0f', 'e47e6210-4879-4e5b-bf21-9285d2713123', '30db3f02-8631-4c11-bba9-74b40c6ebb1c', 'Oxibar DFC 707', 'epoxy', 'Acabamento', 82, 8.2, 100, 120, 10, 'RENNER', 'Branco', 10, 15, '697c8d9210fff6d9214c398f', NULL, '#ffffff', '420.0000', '', NOW(), NOW());
INSERT INTO gpi.parts (id, organization_id, project_id, description, dimensions, weight, type, area, quantity, notes, created_at, updated_at) VALUES ('d9a46d4e-65bd-4fab-a45a-9c35f0373f6a', 'e47e6210-4879-4e5b-bf21-9285d2713123', '30db3f02-8631-4c11-bba9-74b40c6ebb1c', 'Peças diversas (outras)', NULL, 6880, 'Peças diversas (outras)', 450, 1, 'Colunas , vigas e terças (perfi UE) . Todos similares em area de pintura por tipo de peça', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('c71251e8-d5ad-4bda-9af5-4ee58a0d25a6', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2256132', 160, 'L', '697cc1fe5e1c0a9d4ebb92e4', NULL, '2026-07-12T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('35a3ee6a-6842-41c5-9348-569ffd18630b', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2258096', 14.4, 'L', '6974ac5112e9ddef6c122b81', NULL, '2027-01-11T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('1283b55c-2ceb-4285-b0b6-b10fcd9aa469', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2280862', 80, 'L', '6974ac5112e9ddef6c122b81', NULL, '2026-12-08T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('1b7e7116-8c3f-4b26-b789-bf1ced9563fa', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2256094', 18, 'L', '697cc1fe5e1c0a9d4ebb92e4', NULL, '2027-03-04T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('68297f1e-4248-45de-9447-d42aafd22665', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2217893', 60, 'L', '6974ac5112e9ddef6c122b81', NULL, '2026-01-30T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('d7c9eb46-300b-41b8-8055-5ef59e2fb3e8', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2259482', 20, 'L', '697cc1fe5e1c0a9d4ebb92e4', NULL, '2026-12-01T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES ('79a45e7b-b2e6-4c3f-852f-739028321142', 'e47e6210-4879-4e5b-bf21-9285d2713123', NULL, NULL, '2293040', 80, 'L', '697c8d9210fff6d9214c398f', NULL, '2027-08-30T00:00:00.000Z', NULL, '', NOW(), NOW());
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('0316dc9b-5fe7-4404-aff2-ff4abd9aeae3', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Item Vencido', 'O item 0107/24 - Lote 2217893 venceu em 29/01/2026.', 'error', false, false, '2026-02-12T12:37:19.420Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('39a10f4f-6c34-4bba-b863-9adb347bcb34', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Estoque Baixo!', 'O item 0305/25 (Rethane FHB 658) está com apenas 18L. (Mínimo: 20L)', 'error', false, false, '2026-02-12T12:40:46.684Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('eda70cc2-968b-4cce-a29e-7cd147b3a8c3', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Estoque Baixo (Total)', 'O produto Revran DST QD 721 (Cor: Cinza N6.5) atingiu o nível crítico. Total: 94.4L. (Mínimo: 100L)', 'error', false, false, '2026-02-12T13:26:38.421Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('d6d4cf44-bf16-4e24-a09f-3d33c0d2ec24', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Nova Ficha Técnica', 'A ficha técnica "Diluente para Epoxi (Revran)" (RENNER) foi adicionada à biblioteca.', 'info', false, false, '2026-02-12T14:33:21.615Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('6d470211-1f46-4f44-929e-f4048e71da30', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Nova Ficha Técnica', 'A ficha técnica "Diluente para tintas PU e Esmalte Sintético (Rethanne)" (RENNER) foi adicionada à biblioteca.', 'info', false, false, '2026-02-12T14:35:04.846Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('ea8be6bd-bd64-4719-8154-87df66ea07a4', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Recebimento de Material', 'Recebido: 12L de 4444 (Lote: N/A).', 'info', false, false, '2026-02-12T19:01:56.081Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('bf315f52-984f-436e-8323-2f17841807b1', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Estoque Baixo (Total)', 'O produto Diluente para Epoxi (Revran) (Cor: N/A) atingiu o nível crítico. Total: 7.0L. (Mínimo: 10L)', 'error', false, false, '2026-02-12T19:02:57.679Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('e2514cbc-0dd4-48cb-a4dd-336574dcc510', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Atualização de Obra', 'O peso da obra "B129 - Rampa Toyota" foi atualizado para 11925kg.', 'info', true, false, '2026-02-25T13:08:33.461Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('d7ef7575-c21b-499d-b9dc-bfb42d7302ac', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Atualização de Obra', 'O peso da obra "B129 - Rampa Toyota" foi atualizado para 12430kg.', 'info', false, false, '2026-02-25T17:54:11.359Z');
INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES ('ca87d8aa-e4f6-43bc-9316-6da0e2e185b4', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Item Vencido', 'O item 0107/24 - Lote 2217893 venceu em 1/30/2026.', 'error', false, false, '2026-03-14T15:52:41.972Z');
INSERT INTO gpi.yield_studies (id, organization_id, name, data_sheet_id, target_dft, dilution_percent, categories, total_weight, estimated_paint_volume, estimated_reducer_volume, estimated_paint_volume_by_area, estimated_reducer_volume_by_area, average_complexity, created_at, updated_at) VALUES ('6f9f5a49-90cf-47da-815d-3e726631e9fe', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Novo Estudo - 04/02/26 18:41', '697cc1fe5e1c0a9d4ebb92e4', 120, 10, '[{"name":"Calhas","weight":1.3,"area":160,"historicalYield":12,"historicalDft":120,"efficiency":10,"_id":"6983bd1c94117d5e52856de1"},{"name":"Vigas leves","weight":2,"area":200,"historicalYield":12,"historicalDft":100,"efficiency":24,"_id":"6983bf5294117d5e52856dfc"}]', 3.3, 48.91, 4.89, 75.59, 7.56, 1, NOW(), NOW());
INSERT INTO gpi.yield_studies (id, organization_id, name, data_sheet_id, target_dft, dilution_percent, categories, total_weight, estimated_paint_volume, estimated_reducer_volume, estimated_paint_volume_by_area, estimated_reducer_volume_by_area, average_complexity, created_at, updated_at) VALUES ('b9663cc7-0abe-4955-b2fc-05258bae6e88', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Novo Estudo - 05/02/26 16:07', '697cc1fe5e1c0a9d4ebb92e4', 120, 10, '[{"name":"Estrutura Primária","weight":1000,"area":0,"historicalYield":150,"historicalDft":120,"efficiency":85,"_id":"6984ea6a71299e618edb7c7c"}]', 1000, 0, 0, NULL, NULL, 1, NOW(), NOW());
INSERT INTO gpi.yield_studies (id, organization_id, name, data_sheet_id, target_dft, dilution_percent, categories, total_weight, estimated_paint_volume, estimated_reducer_volume, estimated_paint_volume_by_area, estimated_reducer_volume_by_area, average_complexity, created_at, updated_at) VALUES ('4d731b8f-968f-4039-8d3f-98d9d2327fd1', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'Novo Estudo - 09/02/26 10:25', '698637f3059732a5be0b3535', 120, 10, '[{"name":"Estrutura Primária","weight":1000,"area":0,"historicalYield":150,"historicalDft":120,"efficiency":85,"_id":"6989e053e35a6917a7779bc6"}]', 1000, 0, 0, NULL, NULL, 1, NOW(), NOW());
INSERT INTO gpi.yield_studies (id, organization_id, name, data_sheet_id, target_dft, dilution_percent, categories, total_weight, estimated_paint_volume, estimated_reducer_volume, estimated_paint_volume_by_area, estimated_reducer_volume_by_area, average_complexity, created_at, updated_at) VALUES ('bd7d8c62-4a14-4937-bdb9-bfa363e93151', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'ESTUDO TOYOTA OFICIAL - 25/02/26', '6974ac5112e9ddef6c122b7e', 120, 20, '[{"name":"Vigas leves","weight":0.62,"area":18,"historicalYield":11,"historicalDft":120,"efficiency":70,"_id":"699ef49982121ff9fb05b214"},{"name":"Vigas leves","weight":3.86,"area":152,"historicalYield":12,"historicalDft":100,"efficiency":70,"_id":"699ef8c425baf4a3df44f593"},{"name":"Chapas de pisos (>0,5m²)","weight":6.2,"area":161,"historicalYield":7,"historicalDft":100,"efficiency":90,"_id":"699ef8c425baf4a3df44f594"},{"name":"Vigas leves","weight":1.7,"area":62,"historicalYield":13,"historicalDft":100,"efficiency":65,"_id":"699ef8c425baf4a3df44f595"}]', 12.379999999999999, 118.64, 23.73, 75.68, 15.14, 1, NOW(), NOW());
INSERT INTO gpi.messages (id, organization_id, message, from_user_id, to_user_id, is_read, created_at) VALUES ('3f4a7b79-1fd8-4f5a-bf8d-ebbb66e443c9', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'teste de mensagem 1', NULL, NULL, false, '2026-02-11T10:50:53.420Z');
INSERT INTO gpi.stock_audit_logs (id, organization_id, stock_item_id, action, quantity_before, quantity_after, performed_by, details, created_at) VALUES ('2d7ea21b-60bc-4883-b3b6-702491cb72dd', 'e47e6210-4879-4e5b-bf21-9285d2713123', '6984ba67b558533c8f30f419', 'UPDATE', NULL, NULL, NULL, 'Edição de Movimentação (ADJUSTMENT): Qtd -15 -> -14', '2026-02-06T20:42:23.410Z');
INSERT INTO gpi.stock_audit_logs (id, organization_id, stock_item_id, action, quantity_before, quantity_after, performed_by, details, created_at) VALUES ('bf23c460-67c3-465c-9f47-ce6a9e7a55d8', 'e47e6210-4879-4e5b-bf21-9285d2713123', '6984ba67b558533c8f30f419', 'UPDATE', NULL, NULL, NULL, 'Edição de Movimentação (ADJUSTMENT): Qtd -14 -> -14', '2026-02-06T20:42:46.091Z');
INSERT INTO gpi.stock_audit_logs (id, organization_id, stock_item_id, action, quantity_before, quantity_after, performed_by, details, created_at) VALUES ('9288f2ea-11af-4567-bc3c-26a760216969', 'e47e6210-4879-4e5b-bf21-9285d2713123', '6984ba67b558533c8f30f419', 'UPDATE', NULL, NULL, NULL, 'Edição de Movimentação (ADJUSTMENT): Qtd -14 -> -14', '2026-02-06T20:47:21.549Z');
INSERT INTO gpi.stock_audit_logs (id, organization_id, stock_item_id, action, quantity_before, quantity_after, performed_by, details, created_at) VALUES ('6dd63755-920d-4845-8740-6a371b65b9c5', 'e47e6210-4879-4e5b-bf21-9285d2713123', '6984ba67b558533c8f30f419', 'UPDATE', NULL, NULL, NULL, 'Edição de Movimentação (#4 ADJUSTMENT): Qtd -3 -> -2', '2026-02-06T21:11:30.917Z');
INSERT INTO gpi.system_settings (id, organization_id, key, value, updated_at) VALUES ('01ce7ceb-5fdd-4c4e-9e05-3807eba280e0', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'global', '{"appName":"SteelPaint","appSubtitle":"Gestão de Pintura Industrial","createdAt":"2026-02-04T10:42:20.738Z","updatedAt":"2026-02-12T20:20:05.747Z","__v":0,"appLogoUrl":"/api/system-settings/logo-image/logo-1770927600175-e0488184-c2a1-4ba1-a8f4-eac55e875c94.png","updatedBy":"admtracksteel@gmail.com"}', '2026-02-12T20:20:05.747Z');
INSERT INTO gpi.stored_files (id, organization_id, filename, mime_type, size_bytes, storage_path, metadata, created_at) VALUES ('52cdfbc6-e9c9-4759-b59c-d1b8a76ea617', 'e47e6210-4879-4e5b-bf21-9285d2713123', 'E-412_Revran-DST-PLUS-727-BV_V01.pdf', NULL, 249614, NULL, NULL, NULL);
INSERT INTO gpi.stored_files (id, organization_id, filename, mime_type, size_bytes, storage_path, metadata, created_at) VALUES ('52885971-7e82-4ed7-afaf-b908c74c67eb', 'e47e6210-4879-4e5b-bf21-9285d2713123', '0590_Revran-DST-PLUS-727_V06.pdf', NULL, 182807, NULL, NULL, NULL);
-17
View File
@@ -1,17 +0,0 @@
-- Criar tabela de mensagens no schema gpi
CREATE TABLE IF NOT EXISTS gpi.messages (
id uuid DEFAULT uuid_generate_v4() PRIMARY KEY,
organization_id uuid REFERENCES gpi.organizations(id) ON DELETE CASCADE,
from_user_id uuid REFERENCES gpi.users(id) ON DELETE SET NULL,
to_user_id uuid REFERENCES gpi.users(id) ON DELETE SET NULL,
message text NOT NULL,
is_read boolean DEFAULT false,
read_at timestamp with time zone,
is_archived boolean DEFAULT false,
is_deleted_by_recipient boolean DEFAULT false,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
-- Permissões para as roles do Supabase
GRANT ALL ON gpi.messages TO postgres, anon, authenticated, service_role;
-31
View File
@@ -1,31 +0,0 @@
-- Script para expor as tabelas do schema 'gpi' no schema 'public' via views
-- Isso facilita o acesso pela API padrão do Supabase/PostgREST
-- 1. Garantir que o schema public existe
CREATE SCHEMA IF NOT EXISTS public;
-- 2. Criar views no schema public para cada tabela do gpi
CREATE OR REPLACE VIEW public.organizations AS SELECT * FROM gpi.organizations;
CREATE OR REPLACE VIEW public.users AS SELECT * FROM gpi.users;
CREATE OR REPLACE VIEW public.projects AS SELECT * FROM gpi.projects;
CREATE OR REPLACE VIEW public.parts AS SELECT * FROM gpi.parts;
CREATE OR REPLACE VIEW public.painting_schemes AS SELECT * FROM gpi.painting_schemes;
CREATE OR REPLACE VIEW public.application_records AS SELECT * FROM gpi.application_records;
CREATE OR REPLACE VIEW public.inspections AS SELECT * FROM gpi.inspections;
CREATE OR REPLACE VIEW public.technical_data_sheets AS SELECT * FROM gpi.technical_data_sheets;
CREATE OR REPLACE VIEW public.yield_studies AS SELECT * FROM gpi.yield_studies;
CREATE OR REPLACE VIEW public.instruments AS SELECT * FROM gpi.instruments;
CREATE OR REPLACE VIEW public.stock_items AS SELECT * FROM gpi.stock_items;
CREATE OR REPLACE VIEW public.stock_movements AS SELECT * FROM gpi.stock_movements;
CREATE OR REPLACE VIEW public.notifications AS SELECT * FROM gpi.notifications;
CREATE OR REPLACE VIEW public.geometry_types AS SELECT * FROM gpi.geometry_types;
CREATE OR REPLACE VIEW public.messages AS SELECT * FROM gpi.messages;
CREATE OR REPLACE VIEW public.stock_audit_logs AS SELECT * FROM gpi.stock_audit_logs;
CREATE OR REPLACE VIEW public.system_settings AS SELECT * FROM gpi.system_settings;
CREATE OR REPLACE VIEW public.stored_files AS SELECT * FROM gpi.stored_files;
-- 3. Dar permissões de acesso às views
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO postgres, anon, authenticated, service_role;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO postgres, anon, authenticated, service_role;
SELECT '18 views criadas no schema public com sucesso!' AS resultado;
+19
View File
@@ -0,0 +1,19 @@
import { query } from './src/server/config/database.js';
async function main() {
try {
console.log("Creating gpi.files...");
await query("CREATE TABLE IF NOT EXISTS gpi.files (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), filename TEXT NOT NULL, content_type TEXT NOT NULL, data BYTEA NOT NULL, size INTEGER NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW());");
console.log("Creating gpi.stock_audit_logs...");
await query("CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID, stock_item_id UUID, movement_id UUID, movement_number INTEGER, user_id TEXT, user_name TEXT, action TEXT, details TEXT, old_values JSONB, new_values JSONB, timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW());");
console.log("Done.");
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
main();
-13
View File
@@ -1,13 +0,0 @@
-- Habilitar schema gpi na PostgREST API
-- As permissões abaixo são suficientes para a PostgREST expor o schema
-- Permissões
GRANT USAGE ON SCHEMA gpi TO postgres, anon, authenticated, service_role;
-- Grant em todas as tabelas existentes do schema gpi
GRANT ALL ON ALL TABLES IN SCHEMA gpi TO postgres, anon, authenticated, service_role;
-- Grant em sequências
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA gpi TO postgres, anon, authenticated, service_role;
SELECT 'Schema gpi habilitado na PostgREST API!' AS result;
-337
View File
@@ -1,337 +0,0 @@
DROP SCHEMA IF EXISTS gpi CASCADE;
-- Criar schema gpi
CREATE SCHEMA IF NOT EXISTS gpi;
-- Tabela organizations
CREATE TABLE IF NOT EXISTS gpi.organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Inserir organização padrão
INSERT INTO gpi.organizations (id, name)
VALUES ('e47e6210-4879-4e5b-bf21-9285d2713123', 'Organização Migrada')
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS gpi.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
logto_id TEXT UNIQUE,
email TEXT NOT NULL,
name TEXT,
role TEXT DEFAULT 'user',
is_banned BOOLEAN DEFAULT false,
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS gpi.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
client TEXT,
start_date DATE,
end_date DATE,
environment TEXT,
technician TEXT,
weight_kg DECIMAL(10,2),
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'archived')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela parts
CREATE TABLE IF NOT EXISTS gpi.parts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
description TEXT,
dimensions TEXT,
weight DECIMAL(10,3),
type TEXT,
area DECIMAL(10,3),
complexity INTEGER DEFAULT 1,
quantity INTEGER NOT NULL DEFAULT 1,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela painting_schemes
CREATE TABLE IF NOT EXISTS gpi.painting_schemes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT,
coat TEXT,
solids_volume DECIMAL(12,3),
yield_theoretical DECIMAL(12,3),
eps_min DECIMAL(12,3),
eps_max DECIMAL(12,3),
dilution DECIMAL(12,3),
manufacturer TEXT,
color TEXT,
paint_consumption DECIMAL(12,3),
thinner_consumption DECIMAL(12,3),
paint_id TEXT,
thinner_id TEXT,
color_hex TEXT,
thinner_symbol TEXT,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela application_records
CREATE TABLE IF NOT EXISTS gpi.application_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
coat_stage TEXT NOT NULL,
piece_description TEXT,
date DATE,
operator TEXT,
real_weight DECIMAL(10,3),
volume_used DECIMAL(10,3),
area_painted DECIMAL(10,3),
wet_thickness_avg DECIMAL(6,2),
dry_thickness_calc DECIMAL(6,2),
real_yield DECIMAL(10,3),
method TEXT,
diluent_used DECIMAL(10,3),
items JSONB,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela inspections
CREATE TABLE IF NOT EXISTS gpi.inspections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
application_record_id UUID REFERENCES gpi.application_records(id),
stock_item_id TEXT,
instrument_id TEXT,
type TEXT CHECK (type IN ('painting', 'surface_treatment')),
date DATE,
inspector TEXT,
part_temperature DECIMAL(6,2),
weight_kg DECIMAL(10,3),
appearance TEXT,
defects TEXT,
photos TEXT[],
piece_description TEXT,
eps_points DECIMAL(6,2)[],
adhesion_test TEXT,
batch TEXT,
treatment_executor TEXT,
treatment_type TEXT,
cleaning_degree TEXT,
roughness_readings DECIMAL(6,2)[],
flash_rust TEXT,
temperature DECIMAL(6,2),
relative_humidity DECIMAL(5,2),
period TEXT CHECK (period IN ('morning', 'afternoon', 'night')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela technical_data_sheets
CREATE TABLE IF NOT EXISTS gpi.technical_data_sheets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
manufacturer TEXT,
type TEXT,
file_url TEXT,
upload_date DATE,
solids_volume DECIMAL(12,3),
density DECIMAL(12,3),
mixing_ratio TEXT,
yield_theoretical DECIMAL(12,3),
wft_min DECIMAL(12,3),
wft_max DECIMAL(12,3),
dft_min DECIMAL(12,3),
dft_max DECIMAL(12,3),
reducer TEXT,
mixing_ratio_weight TEXT,
mixing_ratio_volume TEXT,
dft_reference DECIMAL(12,3),
yield_factor DECIMAL(12,3),
dilution DECIMAL(12,3),
notes TEXT,
manufacturer_code TEXT,
min_stock DECIMAL(12,3),
typical_application TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela yield_studies
CREATE TABLE IF NOT EXISTS gpi.yield_studies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
data_sheet_id TEXT NOT NULL,
target_dft DECIMAL(6,2) NOT NULL,
dilution_percent DECIMAL(5,2) NOT NULL,
categories JSONB NOT NULL,
total_weight DECIMAL(10,3),
estimated_paint_volume DECIMAL(10,3),
estimated_reducer_volume DECIMAL(10,3),
estimated_paint_volume_by_area DECIMAL(10,3),
estimated_reducer_volume_by_area DECIMAL(10,3),
average_complexity DECIMAL(3,1),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela instruments
CREATE TABLE IF NOT EXISTS gpi.instruments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
serial_number TEXT,
manufacturer TEXT,
model TEXT,
last_calibration DATE,
next_calibration DATE,
status TEXT DEFAULT 'active',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela stock_items
CREATE TABLE IF NOT EXISTS gpi.stock_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT,
type TEXT,
batch_number TEXT,
quantity DECIMAL(12,3) DEFAULT 0,
unit TEXT DEFAULT 'L',
data_sheet_id TEXT,
location TEXT,
expiration_date DATE,
status TEXT DEFAULT 'available',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela stock_movements
CREATE TABLE IF NOT EXISTS gpi.stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
stock_item_id UUID REFERENCES gpi.stock_items(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('in', 'out', 'adjustment')),
quantity DECIMAL(10,3) NOT NULL,
reason TEXT,
performed_by TEXT,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela notifications
CREATE TABLE IF NOT EXISTS gpi.notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
type TEXT DEFAULT 'info' CHECK (type IN ('info', 'warning', 'error', 'success')),
is_read BOOLEAN DEFAULT false,
is_archived BOOLEAN DEFAULT false,
archived_by TEXT[],
deleted_by TEXT[],
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela geometry_types
CREATE TABLE IF NOT EXISTS gpi.geometry_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name TEXT NOT NULL,
efficiency_loss DECIMAL(5,2),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela messages
CREATE TABLE IF NOT EXISTS gpi.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
from_user_id UUID,
to_user_id UUID,
message TEXT NOT NULL,
is_read BOOLEAN DEFAULT false,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela stock_audit_logs
CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
stock_item_id TEXT,
action TEXT NOT NULL,
quantity_before DECIMAL(12,3),
quantity_after DECIMAL(12,3),
performed_by TEXT,
details TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela system_settings
CREATE TABLE IF NOT EXISTS gpi.system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
key TEXT UNIQUE NOT NULL,
value JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tabela stored_files (Metadados dos PDFs)
CREATE TABLE IF NOT EXISTS gpi.stored_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
filename TEXT NOT NULL,
mime_type TEXT,
size_bytes BIGINT,
storage_path TEXT, -- Caminho no Supabase Storage
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Habilitar PostgREST para o schema gpi
GRANT USAGE ON SCHEMA gpi TO postgres, anon, authenticated, service_role;
-- Grant permissions em todas as tabelas
GRANT ALL ON TABLE gpi.organizations TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.users TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.projects TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.parts TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.painting_schemes TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.application_records TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.inspections TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.technical_data_sheets TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.yield_studies TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.instruments TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.stock_items TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.stock_movements TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.notifications TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.geometry_types TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.messages TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.stock_audit_logs TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.system_settings TO postgres, anon, authenticated, service_role;
GRANT ALL ON TABLE gpi.stored_files TO postgres, anon, authenticated, service_role;
-- Grant sequences
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA gpi TO postgres, anon, authenticated, service_role;
SELECT 'Schema gpi criado com sucesso!' AS result;
-173
View File
@@ -1,173 +0,0 @@
# GPI — Gestor de Pintura Industrial
## Descritivo Publicitário para Landing Page
---
## Título de Alto Impacto
**Controle total da pintura industrial: de projeto a inspeção final, em um único sistema.**
---
## 3 Benefícios Principais
### 1. Gestão Completa do Projeto de Pintura
Cadastre projetos com classificação ambiental ISO 12944 (C1 a C5, CX), controle de peso por evolução, e acompanhamento em tempo real. Cada projeto integra peças, esquemas de pintura, controle de aplicação e inspeções — tudo conectado e auditável.
### 2. Ferramentas Técnicas Integradas
Calculadora de ponto de orvalho, conversor μm/mils, cálculos de EPS/EPU, volume morto, consumo por área e por peso, e seletor de bicos airless — todas as ferramentas que o pintor e o engenheiro precisam, sem sair do sistema.
### 3. Análise de Conformidade e Rastreabilidade Total
Comparativo automático entre valores teóricos e reais (rendimento, diluição, espessura DFT). Relatório por peça/lote com status: Aprovado, Atenção ou Crítico. Inspections registram EPS, aderência, rugosidade e condições ambientais — com证据 visual.
---
## Análise Resumida de Todos os Recursos
### Gestão de Projetos
- Cadastro de projetos com cliente, ingeniero responsável, datas e ambiente (ISO 12944)
- Acompanhamento de evolução por peso (kg pintados vs total)
- Arquivo/restore de projetos
- Relatórios gerais e analíticos por projeto
### Cadastro de Peças e Geometrias
- Peças com tipo de geometria (viga, chapas, tubulares, etc.)
- Perda de eficiência por tipo de geometria (tabela configurável)
- Cálculo automático de área superficial e peso
- Vinculação a esquemas de pintura
### Esquemas de Pintura
- Cadastro de esquemas com camadas (Primer, Intermediate, Finish)
- EPS mínima e máxima requerida (μm)
- Sólidos por volume (%)
- Cor e código de cor com bolinha visual
- Diluente/reductor vinculado
- Clone de esquemas entre projetos
- Troca/importação de esquemas entre projetos
### Controle de Aplicação (Registros Diários)
- Registro de demãos por data, etapa e peça
- Espessura úmida aplicada (EPU)
- Espessura seca calculada (EPS)
- Operador, método de aplicação, diluente utilizado
- Volume de tinta e diluente consumidos
### Inspeções de Qualidade
- **Tratamento Superficial**: Tipo de jateamento (seco, hidrojato, mecânico, manual), grau de limpeza (Sa 1 a Sa 3, St2/St3, WJ-1 a WJ-4), flash rust, 5 leituras de rugosidade com média automática
- **Pintura**: Mínimo 10 pontos de EPS, teste de aderência, aparência (aprovado/reprovado/ressalvas), lote de tinta utilizado
- **Condições Ambientais**: Temperatura, umidade relativa, temperatura da peça, período (manhã/tarde/noite)
- Evolução por peso com gráfico visual
### Biblioteca Técnica de Produtos (Fichas)
- Upload de PDF com extração automática de dados via OCR (pdf-parse)
- Cadastro de nome, fabricante, tipo de tinta
- Sólidos por volume, rendimento teórico, fator de rendimento (m².μm/L)
- Intervalos WFT e DFT
- Diluição recomendada
- Relação de mistura (peso e volume)
- Download do PDF técnico
### Estudos de Rendimento (Yield Studies)
- Criação de estudos por tinta (ficha técnica)
- Categorias de peças (Estrutura Primária, Secundária, etc.)
- Dual calculation: por peso (L/t) e por área (m²)
- DFT alvo configurável
- EPU calculado automaticamente
- Projeção de consumo com variação DFT (±10%, ±20%, ±30%)
- Gráfico de barras por categoria
- Relatório PDF para impressão
### Estoque de Tintas e Diluentes
- Cadastro de lotes com RR, número de lote, quantidade, unidade
- Validade/expiration date com alerta
- Estoque mínimo com alerta visual de baixa quantidade
- Baixa de estoque por registro de aplicação
- Histórico completo de movimentações por lote
- Relatório de inventário para impressão
-分开 tintas (PAINT) e diluentes (THINNER)
### Análise de Conformidade
- Comparativo teórico vs real por peça/lote
- Rendimento (m²/L): real vs meta
- Diluição (%): real vs máximo permitido
- Espessura DFT: real vs range do esquema
- Status global: Aprovado / Atenção / Crítico
- Baseado na NBR 12103
### Calculadora Técnica
- **Conversões**: Microns (μm) ↔ Mils
- **Espessura**: Cálculo de EPS (seca) a partir de EPU, SV e diluição; Cálculo de EPU (úmida) a partir de EPS, SV e diluição; Volume morto (rugosidade + área)
- **Ambiente**: Ponto de orvalho com status (risco/seguro)
- **Consumo e Custo**: Por área (m²), por peso (L/t), estimativa de custos com preço por litro
- **Bicos Airless**: Código -> Ângulo do leque, vazão, descrição do uso (fino/médio/pesado)
### Ferramentas de Administração
- Gerenciamento de usuários (admin, user, guest)
- Configuração de tipos de geometria com perda de eficiência
- Backup e restore do banco de dados
- Diagnóstico de sistema (developer dashboard)
---
## Diferenciais do Mercado Concorrente
| Concorrentes Tradicionais | GPI |
|---------------------------|-----|
| Planilhas Excel isoladas | **Sistema centralizado** com projetos, peças, esquemas e inspeções integrados |
| Cálculos manuais ou calculadora separada | **Ferramentas técnicas integradas** — ponto de orvalho, EPS/EPU, bicos airless |
| Fichas técnicas em papel ou arquivo | **Biblioteca digital com OCR** — extração automática de PDF |
| Análise de conformidade em planilha | **Comparativo automático** teórico vs real com status visual |
| Estoque em planilha ou ERP genérico | **Gestão específica de tintas** com lotes, validade e alertas |
| Estudos de rendimento em papel | **Dual calculation** peso/área com projeção DFT e gráfico |
| Relatórios fragmentados | **Relatórios PDF** de inspeção, inventário e estudos |
| Sem norma técnica | **Conformidade NBR 12103** para análise de rendimento |
---
## Inovações Tecnológicas
1. **PDF Data Extraction via OCR**: Upload de fichas técnicas em PDF com extração automática de sólidos volume, rendimento, DFT — usando pdf-parse e processamento inteligente.
2. **Dual Yield Calculation**: Cálculo de consumo de tinta tanto por peso (L/t) quanto por área (m²) — com fator de eficiência dinâmica por tipo de geometria.
3. **Calculadora de Bicos Airless**: Decodificação de códigos de bicos (ex: 517) em ângulo de leque, vazão e recomendação de uso — ferramenta essencial para pintores.
4. **Análise de Conformidade Automática**: Comparativo em tempo real entre valores teóricos (esquema) e reais (aplicação/inspeção) — com thresholds de 20% (atenção) e 30% (crítico).
5. **Sistema de Estocagem com Lotes e Validade**: Gestão de estoque específica para pintura industrial com追踪 de lotes, datas de validade e alertas de estoque mínimo.
6. **Surface Treatment com 5 Leituras de Rugosidade**: Formulário completo de jateamento com avg/min/max de rugosidade, flash rust e condições ambientais — tudo integrado à inspeção.
7. **Yield Study com Projeção DFT**: Estudos de rendimento com projeção de consumo para variações de espessura (±10%, ±20%, ±30%) e gráfico visual por categoria.
8. **Inspection com Evolução Visual**: Gráfico de pizza mostrando evolução do projeto por peso da inspeção vs peso total — integrado ao relatório de inspeção.
---
## Benefícios Incontestáveis
- **Fim da dispersão**: Tudo — projeto, peça, esquema, controle, inspeção — em um único sistema. Sem planilhas espalhadas, sem planilhas perdidas.
- **Decisões baseadas em dados**: Análise de conformidade automática elimina o "achismo" — o sistema diz se está aprovado, em atenção ou crítico.
- **Precisão nos cálculos**: EPS, EPU, volume morto, ponto de orvalho — todas as fórmulas da norma NBR 12103 implementadas e validadas.
- **Rastreabilidade de lotes**: Cada inspeção registra qual lote de tinta foi usado — informação crítica para recalls e certificações de qualidade.
- **Eliminação de erros de transcrição**: Upload de PDF com extração automática dispensa digitação manual de dados técnicos de fichas.
- **Produtividade no canteiro**: Ferramentas técnicas integradas (calculadora, bicos, conversão) eliminam idas e vindas ao escritório para cálculos.
- **Entregas auditáveis**: Relatórios PDF de inspeção e estudos de rendimento servem como documento de entrega ao cliente — com evidência visual.
- **Configuração por tipo de geometria**: Cada tipo de peça tem sua própria perda de eficiência — estruturas diferentes, cálculos diferentes, sem simplificações.
---
## Parágrafo Final — A Dor da Gestão
Gerenciar pintura industrial é lidar com planilhas desatualizadas, cálculos manuais prone a erros, fichas técnicas em papel espalhadas pelo canteiro, e relatórios fragmentados que não se talking between si. O gestor precisa saber: quanto de tinta foi consumido, se a espessura está dentro da faixa, se há lotes prestes a vencer, e se o rendimento real está dentro do esperado. O **GPI** centraliza projetos, peças, esquemas de pintura, controle de aplicação, inspeções e estoque — com ferramentas técnicas integradas e análise de conformidade automática. Menos planilhas, mais controle, entregas dentro da norma. **Seu próximo projeto de pintura merece um sistema que trabalha tão hard quanto você — do primeiro projeto ao relatório final.**
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAp2sxQE/eO30etME9tLKJjguVPUg3W8k3j2H7F/kBVogAAAJAeNjMSHjYz
EgAAAAtzc2gtZWQyNTUxOQAAACAp2sxQE/eO30etME9tLKJjguVPUg3W8k3j2H7F/kBVog
AAAEDLm78AwM6lbNoz7iVUh1xvlphZzNhitquW4jHyR7lIhCnazFAT947fR60wT20somOC
5U9SDdbyTePYfsX+QFWiAAAAC2FudGlncmF2aXR5AQI=
-----END OPENSSH PRIVATE KEY-----
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnazFAT947fR60wT20somOC5U9SDdbyTePYfsX+QFWi antigravity
+1 -1
View File
@@ -10,7 +10,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" /> <meta name="theme-color" content="#0f172a" />
<link rel="apple-touch-icon" href="/pwa-192x192.png"> <link rel="apple-touch-icon" href="/pwa-192x192.png">
<title>SteelPaint</title> <title>GPI - JWT VERSION 1.6</title>
</head> </head>
<body> <body>
-147
View File
@@ -1,147 +0,0 @@
import { MongoClient } from 'mongodb';
import { execSync } from 'child_process';
import crypto from 'crypto';
import fs from 'fs';
const MONGO_URI = "mongodb+srv://admtracksteel:mongodb26@cluster0.a4xiilu.mongodb.net/ts_gpi";
const DB_NAME = "ts_gpi";
const DEFAULT_ORG_ID = "e47e6210-4879-4e5b-bf21-9285d2713123";
const client = new MongoClient(MONGO_URI);
const idMap = new Map();
function getUUID(mongoId) {
if (!mongoId) return null;
const mid = mongoId.toString();
if (idMap.has(mid)) return idMap.get(mid);
const newUuid = crypto.randomUUID();
idMap.set(mid, newUuid);
return newUuid;
}
function sqlSafe(val) {
if (val === null || val === undefined) return 'NULL';
if (val instanceof Date) return `'${val.toISOString()}'`;
if (Array.isArray(val)) return `'{"${val.join('","')}"}'`;
if (typeof val === 'object' && val.toString && !val.getMonth) { // not a date
const s = val.toString();
return `'${s.replace(/'/g, "''")}'`;
}
if (typeof val === 'string') return `'${val.replace(/'/g, "''")}'`;
return val;
}
async function run() {
try {
await client.connect();
console.log("🚀 Conectado ao MongoDB Atlas...");
const db = client.db(DB_NAME);
let allSQL = [];
// 1. Geometrias (Geometry Types)
const geoms = await db.collection('geometrytypes').find().toArray();
console.log(`📐 Migrando ${geoms.length} tipos de geometria...`);
for (const g of geoms) {
allSQL.push(`INSERT INTO gpi.geometry_types (id, organization_id, name, efficiency_loss, updated_at) VALUES (${sqlSafe(getUUID(g._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(g.name)}, ${sqlSafe(g.efficiencyLoss)}, NOW());`);
}
// 2. Fichas Técnicas (Technical Data Sheets)
const tds = await db.collection('technicaldatasheets').find().toArray();
console.log(`📚 Migrando ${tds.length} fichas técnicas...`);
for (const t of tds) {
allSQL.push(`INSERT INTO gpi.technical_data_sheets (id, organization_id, name, manufacturer, manufacturer_code, type, min_stock, typical_application, solids_volume, density, mixing_ratio, yield_theoretical, wft_min, wft_max, dft_min, dft_max, reducer, mixing_ratio_weight, mixing_ratio_volume, dft_reference, yield_factor, dilution, notes, created_at, updated_at) VALUES (${sqlSafe(getUUID(t._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(t.name)}, ${sqlSafe(t.manufacturer)}, ${sqlSafe(t.manufacturerCode)}, ${sqlSafe(t.type)}, ${sqlSafe(t.minStock)}, ${sqlSafe(t.typicalApplication)}, ${sqlSafe(t.solidsVolume)}, ${sqlSafe(t.density)}, ${sqlSafe(t.mixingRatio)}, ${sqlSafe(t.yieldTheoretical)}, ${sqlSafe(t.wftMin)}, ${sqlSafe(t.wftMax)}, ${sqlSafe(t.dftMin)}, ${sqlSafe(t.dftMax)}, ${sqlSafe(t.reducer)}, ${sqlSafe(t.mixingRatioWeight)}, ${sqlSafe(t.mixingRatioVolume)}, ${sqlSafe(t.dftReference)}, ${sqlSafe(t.yieldFactor)}, ${sqlSafe(t.dilution)}, ${sqlSafe(t.notes)}, ${sqlSafe(t.createdAt)}, ${sqlSafe(t.updatedAt)});`);
}
// 3. Projetos (Projects)
const projs = await db.collection('projects').find().toArray();
console.log(`📦 Migrando ${projs.length} projetos...`);
for (const p of projs) {
allSQL.push(`INSERT INTO gpi.projects (id, organization_id, name, client, start_date, end_date, environment, technician, weight_kg, status, created_at, updated_at) VALUES (${sqlSafe(getUUID(p._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(p.name)}, ${sqlSafe(p.client)}, ${sqlSafe(p.startDate)}, ${sqlSafe(p.endDate)}, ${sqlSafe(p.environment)}, ${sqlSafe(p.technician)}, ${sqlSafe(p.weightKg)}, 'active', ${sqlSafe(p.createdAt)}, ${sqlSafe(p.updatedAt)});`);
}
// 4. Esquemas de Pintura (Painting Schemes)
const schemes = await db.collection('paintingschemes').find().toArray();
console.log(`🎨 Migrando ${schemes.length} esquemas de pintura...`);
for (const s of schemes) {
allSQL.push(`INSERT INTO gpi.painting_schemes (id, organization_id, project_id, name, type, coat, solids_volume, yield_theoretical, eps_min, eps_max, dilution, manufacturer, color, paint_consumption, thinner_consumption, paint_id, thinner_id, color_hex, thinner_symbol, notes, created_at, updated_at) VALUES (${sqlSafe(crypto.randomUUID())}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(getUUID(s.projectId))}, ${sqlSafe(s.name)}, ${sqlSafe(s.type)}, ${sqlSafe(s.coat)}, ${sqlSafe(s.solidsVolume)}, ${sqlSafe(s.yieldTheoretical)}, ${sqlSafe(s.epsMin)}, ${sqlSafe(s.epsMax)}, ${sqlSafe(s.dilution)}, ${sqlSafe(s.manufacturer)}, ${sqlSafe(s.color)}, ${sqlSafe(s.paintConsumption)}, ${sqlSafe(s.thinnerConsumption)}, ${sqlSafe(s.paintId)}, ${sqlSafe(s.thinnerId)}, ${sqlSafe(s.colorHex)}, ${sqlSafe(s.thinnerSymbol)}, ${sqlSafe(s.notes)}, NOW(), NOW());`);
}
// 5. Peças (Parts)
const parts = await db.collection('parts').find().toArray();
console.log(`🧩 Migrando ${parts.length} peças...`);
for (const pt of parts) {
allSQL.push(`INSERT INTO gpi.parts (id, organization_id, project_id, description, dimensions, weight, type, area, quantity, notes, created_at, updated_at) VALUES (${sqlSafe(getUUID(pt._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(getUUID(pt.projectId))}, ${sqlSafe(pt.description)}, ${sqlSafe(pt.dimensions)}, ${sqlSafe(pt.weight)}, ${sqlSafe(pt.type)}, ${sqlSafe(pt.area)}, ${sqlSafe(pt.quantity)}, ${sqlSafe(pt.notes)}, NOW(), NOW());`);
}
// 6. Itens de Estoque (Stock Items)
const stockItems = await db.collection('stockitems').find().toArray();
console.log(`🏭 Migrando ${stockItems.length} itens de estoque...`);
for (const item of stockItems) {
allSQL.push(`INSERT INTO gpi.stock_items (id, organization_id, name, type, batch_number, quantity, unit, data_sheet_id, location, expiration_date, status, notes, created_at, updated_at) VALUES (${sqlSafe(getUUID(item._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(item.name)}, ${sqlSafe(item.type)}, ${sqlSafe(item.batchNumber)}, ${sqlSafe(item.quantity)}, ${sqlSafe(item.unit)}, ${sqlSafe(item.dataSheetId)}, ${sqlSafe(item.location)}, ${sqlSafe(item.expirationDate)}, ${sqlSafe(item.status)}, ${sqlSafe(item.notes)}, NOW(), NOW());`);
}
// 7. Notificações (Notifications)
const notifs = await db.collection('notifications').find().toArray();
console.log(`🔔 Migrando ${notifs.length} notificações...`);
for (const n of notifs) {
allSQL.push(`INSERT INTO gpi.notifications (id, organization_id, title, message, type, is_read, is_archived, created_at) VALUES (${sqlSafe(getUUID(n._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(n.title)}, ${sqlSafe(n.message)}, ${sqlSafe(n.type)}, ${sqlSafe(n.isRead)}, ${sqlSafe(n.isArchived)}, ${sqlSafe(n.createdAt)});`);
}
// 8. Estudos de Rendimento (Yield Studies)
const yields = await db.collection('yieldstudies').find().toArray();
console.log(`📈 Migrando ${yields.length} estudos de rendimento...`);
for (const y of yields) {
allSQL.push(`INSERT INTO gpi.yield_studies (id, organization_id, name, data_sheet_id, target_dft, dilution_percent, categories, total_weight, estimated_paint_volume, estimated_reducer_volume, estimated_paint_volume_by_area, estimated_reducer_volume_by_area, average_complexity, created_at, updated_at) VALUES (${sqlSafe(getUUID(y._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(y.name)}, ${sqlSafe(y.dataSheetId)}, ${sqlSafe(y.targetDft)}, ${sqlSafe(y.dilutionPercent)}, ${sqlSafe(JSON.stringify(y.categories))}, ${sqlSafe(y.totalWeight)}, ${sqlSafe(y.estimatedPaintVolume)}, ${sqlSafe(y.estimatedReducerVolume)}, ${sqlSafe(y.estimatedPaintVolumeByArea)}, ${sqlSafe(y.estimatedReducerVolumeByArea)}, ${sqlSafe(y.averageComplexity)}, NOW(), NOW());`);
}
// 9. Mensagens (Messages)
const msgs = await db.collection('messages').find().toArray();
console.log(`💬 Migrando ${msgs.length} mensagens...`);
for (const m of msgs) {
allSQL.push(`INSERT INTO gpi.messages (id, organization_id, message, from_user_id, to_user_id, is_read, created_at) VALUES (${sqlSafe(getUUID(m._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(m.content || m.message)}, NULL, NULL, ${sqlSafe(m.isRead)}, ${sqlSafe(m.createdAt)});`);
}
// 10. Logs de Auditoria de Estoque
const auditLogs = await db.collection('stockauditlogs').find().toArray();
console.log(`📝 Migrando ${auditLogs.length} logs de auditoria...`);
for (const al of auditLogs) {
allSQL.push(`INSERT INTO gpi.stock_audit_logs (id, organization_id, stock_item_id, action, quantity_before, quantity_after, performed_by, details, created_at) VALUES (${sqlSafe(getUUID(al._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(al.stockItemId)}, ${sqlSafe(al.action)}, ${sqlSafe(al.quantityBefore)}, ${sqlSafe(al.quantityAfter)}, ${sqlSafe(al.performedBy)}, ${sqlSafe(al.details)}, ${sqlSafe(al.createdAt)});`);
}
// 11. Configurações do Sistema
const settings = await db.collection('systemsettings').find().toArray();
console.log(`⚙️ Migrando ${settings.length} configurações...`);
for (const st of settings) {
const { _id, settingsId, ...rest } = st;
allSQL.push(`INSERT INTO gpi.system_settings (id, organization_id, key, value, updated_at) VALUES (${sqlSafe(getUUID(_id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(settingsId || 'global')}, ${sqlSafe(JSON.stringify(rest))}, ${sqlSafe(st.updatedAt)});`);
}
// 12. Metadados de Arquivos
const files = await db.collection('storedfiles').find().toArray();
console.log(`📁 Migrando ${files.length} metadados de arquivos...`);
for (const f of files) {
allSQL.push(`INSERT INTO gpi.stored_files (id, organization_id, filename, mime_type, size_bytes, storage_path, metadata, created_at) VALUES (${sqlSafe(getUUID(f._id))}, ${sqlSafe(DEFAULT_ORG_ID)}, ${sqlSafe(f.filename)}, ${sqlSafe(f.mimeType)}, ${sqlSafe(f.size)}, ${sqlSafe(f.path)}, ${sqlSafe(JSON.stringify(f.metadata))}, ${sqlSafe(f.createdAt)});`);
}
if (allSQL.length === 0) {
console.log("⚠️ Nenhum dado novo encontrado.");
return;
}
console.log("💾 Executando SQL em massa no Supabase...");
fs.writeFileSync('bulk_migration_final.sql', `TRUNCATE gpi.projects, gpi.technical_data_sheets, gpi.painting_schemes, gpi.parts, gpi.stock_items, gpi.notifications, gpi.yield_studies, gpi.geometry_types, gpi.messages, gpi.stock_audit_logs, gpi.system_settings, gpi.stored_files CASCADE;\n` + allSQL.join('\n'));
execSync(`export PGPASSWORD=Xz0oyb6ArGYG5uAVTVwcvJxRrMuT7EIJ && docker exec -i supabase-db-h0oggskgs0ws0sco8kc4s8ws psql -U supabase_admin -d postgres < bulk_migration_final.sql`);
console.log("✅ Migração final concluída com sucesso!");
} catch (e) {
console.error("❌ ERRO DURANTE MIGRAÇÃO:", e.message);
} finally {
await client.close();
}
}
run();
-70
View File
@@ -1,70 +0,0 @@
console.log('Loading Netlify Function...');
import serverless from 'serverless-http';
import express from 'express';
import cors from 'cors';
// 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()
});
});
// 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);
};
+1509 -1949
View File
File diff suppressed because it is too large Load Diff
+11 -20
View File
@@ -3,27 +3,20 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": {
"node": ">=20.12.0"
},
"scripts": { "scripts": {
"dev": "concurrently \"vite\" \"tsx watch src/server/index.ts\"", "dev": "concurrently \"vite\" \"tsx watch src/server/index.ts\"",
"build:client": "vite build", "build:client": "vite build",
"build:server": "tsc -p tsconfig.server.json", "build:server": "tsc -p tsconfig.server.json",
"prebuild": "npm install", "build": "npm run build:client && npm run build:server",
"build": "npm run build:client",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"start": "tsx src/server/index.ts" "start": "node dist/server/index.js"
}, },
"dependencies": { "dependencies": {
"@logto/node": "^2.4.0",
"@logto/react": "^4.0.13",
"@supabase/supabase-js": "^2.47.0",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vercel/speed-insights": "^1.3.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -31,13 +24,11 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"enhanced-resolve": "^5.18.4", "enhanced-resolve": "^5.18.4",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.6.2", "jsonwebtoken": "^9.0.3",
"helmet": "^8.3.0",
"jose": "^5.2.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mongodb": "^7.1.1",
"multer": "^2.0.2", "multer": "^2.0.2",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"pg": "^8.20.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -45,22 +36,22 @@
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"search-web": "^1.0.3", "search-web": "^1.0.3",
"serverless-http": "^4.0.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"tsx": "^4.21.0",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.0", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vercel/node": "^5.5.28", "@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@@ -74,7 +65,7 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^6.0.0", "vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0"
} }
} }
-1
View File
@@ -1 +0,0 @@
DROP SCHEMA IF EXISTS gpi CASCADE;
-20
View File
@@ -1,20 +0,0 @@
# Segredos e Credenciais
## Supabase
- URL: https://supabase.reifonas.cloud
- Service Role Key: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro
- Anon Key: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJzdWIiOiJ0cnVlIn0.fake_anon_key_for_gpi
## Logto
- URL: https://logto-admin-bzlued1boxl3t8ewsyn99an9.187.77.227.172.sslip.io
- App ID: gpi-app-final
- App Secret: gpi-secret-2026
- Callback URL: https://gpi.reifonas.cloud/callback
## Google OAuth ( usuário )
- Client ID: 516979078541-7f0cm821nls01eb2prtffi5t58phmgiq.apps.googleusercontent.com
- Client Secret: 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
## URLs de Produção
- App: https://gpi.reifonas.cloud
- API: https://gpi.reifonas.cloud/api
+85 -77
View File
@@ -1,6 +1,6 @@
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; // App v1.5 - JWT Migration Final Check
import { AuthProvider } from './context/AuthContext'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/useAuth'; import { AuthProvider, useAuth } from './context/AuthContext';
import { SystemSettingsProvider } from './context/SystemSettingsContext'; import { SystemSettingsProvider } from './context/SystemSettingsContext';
import { NotificationProvider } from './contexts/NotificationContext'; import { NotificationProvider } from './contexts/NotificationContext';
import { Layout } from './components/Layout'; import { Layout } from './components/Layout';
@@ -18,6 +18,7 @@ import { CalculatorDashboard } from './pages/CalculatorDashboard';
import { StockDashboard } from './pages/StockDashboard'; import { StockDashboard } from './pages/StockDashboard';
import { GuestDashboard } from './pages/GuestDashboard'; import { GuestDashboard } from './pages/GuestDashboard';
import { Login } from './pages/Login'; import { Login } from './pages/Login';
import { OrganizationSelector } from './pages/OrganizationSelector';
import InstrumentList from './pages/InstrumentList'; import InstrumentList from './pages/InstrumentList';
const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -30,91 +31,98 @@ const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =
}; };
const AppContent: React.FC = () => { const AppContent: React.FC = () => {
const { isSignedIn, isLoading } = useAuth(); const { appUser, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <div className="flex h-screen items-center justify-center">Carregando...</div>;
if (isLoading) { // AppUser exists but hasn't selected an org yet (if your business logic requires orgs)
return ( if (appUser && !appUser.organizationId) {
<div className="min-h-screen bg-surface-soft flex items-center justify-center"> return <OrganizationSelector />;
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
// If not signed in and not on the callback page, show login
if (!isSignedIn && location.pathname !== '/callback') {
return <Login />;
} }
return ( return (
<ToastProvider> <Layout>
<SystemSettingsProvider> <Routes>
<NotificationProvider> <Route path="/" element={<ProjectList />} />
<Layout> <Route path="/guest-dashboard" element={<GuestDashboard />} />
<Routes> <Route path="/projects" element={<ProjectList />} />
<Route path="/" element={<ProjectList />} /> <Route path="/project/:id" element={<ProjectDetails />} />
<Route path="/login" element={<Login />} /> <Route path="/schemes" element={<SchemesList />} />
<Route path="/guest-dashboard" element={<GuestDashboard />} /> <Route path="/inspections" element={<InspectionsList />} />
<Route path="/projects" element={<ProjectList />} /> <Route path="/library" element={
<Route path="/project/:id" element={<ProjectDetails />} /> <ProtectedRoute allowedRoles={['user', 'admin']}>
<Route path="/schemes" element={<SchemesList />} /> <DataSheetLibrary />
<Route path="/inspections" element={<InspectionsList />} /> </ProtectedRoute>
<Route path="/library" element={ } />
<ProtectedRoute allowedRoles={['user', 'admin']}> <Route path="/instruments" element={
<DataSheetLibrary /> <ProtectedRoute allowedRoles={['user', 'admin', 'guest']}>
</ProtectedRoute> <InstrumentList />
} /> </ProtectedRoute>
<Route path="/instruments" element={ } />
<ProtectedRoute allowedRoles={['user', 'admin', 'guest']}> <Route path="/yield-study" element={
<InstrumentList /> <ProtectedRoute allowedRoles={['user', 'admin']}>
</ProtectedRoute> <YieldStudyDashboard />
} /> </ProtectedRoute>
<Route path="/yield-study" element={ } />
<ProtectedRoute allowedRoles={['user', 'admin']}> <Route path="/calculators" element={<CalculatorDashboard />} />
<YieldStudyDashboard /> <Route
</ProtectedRoute> path="/admin"
} /> element={
<Route path="/calculators" element={<CalculatorDashboard />} /> <ProtectedRoute allowedRoles={['admin']}>
<Route <AdminDashboard />
path="/admin" </ProtectedRoute>
element={ }
<ProtectedRoute allowedRoles={['admin']}> />
<AdminDashboard /> <Route
</ProtectedRoute> path="/stock"
} element={
/> <ProtectedRoute allowedRoles={['user', 'admin', 'guest']}>
<Route <StockDashboard />
path="/stock" </ProtectedRoute>
element={ }
<ProtectedRoute allowedRoles={['user', 'admin', 'guest']}> />
<StockDashboard /> <Route
</ProtectedRoute> path="/developer"
} element={
/> <DeveloperRoute>
<Route <DeveloperDashboard />
path="/developer" </DeveloperRoute>
element={ }
<DeveloperRoute> />
<DeveloperDashboard /> </Routes>
</DeveloperRoute> </Layout>
} );
/> };
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> const MainRouter: React.FC = () => {
</Layout> const { appUser, isLoading } = useAuth();
</NotificationProvider>
</SystemSettingsProvider> if (isLoading) {
</ToastProvider> return <div className="flex h-screen items-center justify-center">Verificando sessão...</div>;
}
return (
<Router>
{!appUser ? (
<Login />
) : (
<AppContent />
)}
</Router>
); );
}; };
function App() { function App() {
return ( return (
<Router> <ToastProvider>
<AuthProvider> <AuthProvider>
<AppContent /> <SystemSettingsProvider>
<NotificationProvider>
<MainRouter />
</NotificationProvider>
</SystemSettingsProvider>
</AuthProvider> </AuthProvider>
</Router> </ToastProvider>
); );
} }

Before

Width:  |  Height:  |  Size: 6.3 MiB

After

Width:  |  Height:  |  Size: 6.3 MiB

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

+29 -11
View File
@@ -2,11 +2,11 @@ import React, { useState } from 'react';
import NotificationBell from './NotificationBell'; import NotificationBell from './NotificationBell';
import { TeamPresence } from './TeamPresence'; import { TeamPresence } from './TeamPresence';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Menu, X, FolderOpen, Layers, ClipboardCheck, TrendingUp, Sun, Moon, HelpCircle, Shield, Wrench, Terminal, LayoutDashboard, Package, Thermometer, User } from 'lucide-react'; 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 { clsx } from 'clsx';
import { TechnicalManual } from './TechnicalManual';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
import { TechnicalManual } from './TechnicalManual';
// import { useSystemSettings } from '../context/SystemSettingsContext';
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
@@ -19,7 +19,8 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches); return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches);
}); });
const location = useLocation(); const location = useLocation();
const { isAdmin, isUser, isDeveloper, appUser } = useAuth(); const { isAdmin, isUser, isDeveloper, appUser, logout } = useAuth();
// const { settings } = useSystemSettings();
// Helper to get role display name // Helper to get role display name
const getRoleDisplay = () => { const getRoleDisplay = () => {
@@ -77,10 +78,6 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
return false; return false;
}; };
if (location.pathname === '/login' || location.pathname === '/callback') {
return <>{children}</>;
}
return ( return (
<div className="min-h-screen bg-surface-soft flex font-sans selection:bg-primary/30"> <div className="min-h-screen bg-surface-soft flex font-sans selection:bg-primary/30">
{/* Sidebar Desktop - Fixed */} {/* Sidebar Desktop - Fixed */}
@@ -104,6 +101,18 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
</div> </div>
</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 */} {/* Team Presence - Shows all members with online/offline status */}
<TeamPresence /> <TeamPresence />
@@ -202,12 +211,20 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
)} )}
</button> </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="pt-2 flex items-center justify-between px-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<NotificationBell /> <NotificationBell />
<div className="w-px h-6 bg-border/50 mx-1"></div> <div className="w-px h-6 bg-border/50 mx-1"></div>
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary"> <div className="w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center font-bold text-xs">
<User size={16} /> {appUser?.name?.substring(0, 2).toUpperCase() || 'US'}
</div> </div>
<div className="flex flex-col"> <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-[10px] text-text-main font-bold truncate max-w-[100px]">{appUser?.name || 'Usuário'}</span>
@@ -335,7 +352,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
"md:pl-68", // Push content on desktop "md:pl-68", // Push content on desktop
"pt-16 md:pt-0" // Add padding on mobile for header "pt-16 md:pt-0" // Add padding on mobile for header
)}> )}>
<div className="max-w-7xl mx-auto px-3.5 sm:px-6 md:px-8 lg:px-12 py-5 sm:py-8 md:py-10 w-full animate-in fade-in slide-in-from-bottom-4 duration-500"> <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} {children}
</div> </div>
</main> </main>
@@ -355,3 +372,4 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
</div> </div>
); );
}; };
+6 -6
View File
@@ -13,15 +13,15 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-2.5 sm:p-4"> <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 sm:rounded-3xl shadow-[var(--shadow-premium)] w-full ${maxWidth} max-h-[92vh] flex flex-col animate-in fade-in zoom-in duration-200 border border-border/40 relative overflow-hidden`}> <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-4 sm:p-5 border-b border-border"> <div className="flex justify-between items-center p-5 border-b border-border">
<h2 className="text-lg sm:text-xl font-bold text-text-main truncate pr-2">{title}</h2> <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 shrink-0" aria-label="Fechar modal"> <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" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="p-4 sm:p-6 overflow-y-auto"> <div className="p-6 overflow-y-auto">
{children} {children}
</div> </div>
</div> </div>
+10 -1
View File
@@ -23,7 +23,16 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
requireEdit = false, requireEdit = false,
redirectTo = '/', redirectTo = '/',
}) => { }) => {
const { appUser, canEdit } = useAuth(); 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 // Check role-based access
if (allowedRoles && appUser && !allowedRoles.includes(appUser.role)) { if (allowedRoles && appUser && !allowedRoles.includes(appUser.role)) {
+24 -28
View File
@@ -6,10 +6,9 @@ import api from '../services/api';
interface OrganizationMember { interface OrganizationMember {
_id: string; _id: string;
id: string;
name: string; name: string;
email: string; email: string;
logto_id: string; userId: string;
role: string; role: string;
} }
@@ -31,7 +30,7 @@ export const TeamPresence: React.FC = () => {
const [isModalOpen, setIsModalOpen] = React.useState(false); const [isModalOpen, setIsModalOpen] = React.useState(false);
// Fetch all members // Fetch all members
const fetchMembers = useCallback(async () => { const fetchMembers = React.useCallback(async () => {
try { try {
const response = await api.get<OrganizationMember[]>('/users'); const response = await api.get<OrganizationMember[]>('/users');
setAllMembers(response.data); setAllMembers(response.data);
@@ -41,7 +40,7 @@ export const TeamPresence: React.FC = () => {
}, []); }, []);
// Fetch pending messages // Fetch pending messages
const fetchPendingMessages = useCallback(async () => { const fetchPendingMessages = React.useCallback(async () => {
try { try {
const response = await api.get<PendingMessage[]>('/messages/pending'); const response = await api.get<PendingMessage[]>('/messages/pending');
setPendingMessages(response.data); setPendingMessages(response.data);
@@ -51,35 +50,35 @@ export const TeamPresence: React.FC = () => {
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
fetchMembers(); if (appUser) {
fetchPendingMessages(); fetchMembers();
fetchPendingMessages();
const memberInterval = setInterval(fetchMembers, 60000); const memberInterval = setInterval(fetchMembers, 60000);
const messageInterval = setInterval(fetchPendingMessages, 30000); const messageInterval = setInterval(fetchPendingMessages, 30000);
return () => {
return () => { clearInterval(memberInterval);
clearInterval(memberInterval); clearInterval(messageInterval);
clearInterval(messageInterval); };
}; }
}, [fetchMembers, fetchPendingMessages]); }, [appUser, fetchMembers, fetchPendingMessages]);
if (allMembers.length === 0) { if (allMembers.length === 0) {
return null; return null;
} }
// Create a Set of active user IDs for fast lookup // Create a Set of active user IDs for fast lookup
const activeUserLogtoIds = new Set(activeUsers.map(u => u.logtoId)); const activeUserEmails = new Set(activeUsers.map(u => u.email));
// Create a map of pending messages by recipient ID // Create a map of pending messages by recipient ID
const pendingMessagesByRecipient = new Map( const pendingMessagesByRecipient = new Map(
(pendingMessages || []).map(msg => [msg.toUser?.email, msg]) pendingMessages.map(msg => [msg.toUser?.email, msg])
); );
const handleMemberClick = (member: OrganizationMember) => { const handleMemberClick = (member: OrganizationMember) => {
if (member.logto_id === appUser?.logtoId) { if (member.email === appUser?.email) {
return; // Don't allow messaging yourself return; // Don't allow messaging yourself
} }
setSelectedUser({ id: member.logto_id, name: member.name }); setSelectedUser({ id: member.email, name: member.name });
setIsModalOpen(true); setIsModalOpen(true);
}; };
@@ -89,7 +88,7 @@ export const TeamPresence: React.FC = () => {
}; };
const handleMessageSent = async () => { const handleMessageSent = async () => {
await fetchPendingMessages(); fetchPendingMessages();
}; };
const getExistingMessage = (member: OrganizationMember) => { const getExistingMessage = (member: OrganizationMember) => {
@@ -102,13 +101,13 @@ export const TeamPresence: React.FC = () => {
<div className="px-6 py-3"> <div className="px-6 py-3">
<div className="mb-2"> <div className="mb-2">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]"> <span className="text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]">
Equipe ({(activeUsers || []).length}/{(allMembers || []).length} online) Equipe ({activeUsers.length}/{allMembers.length} online)
</span> </span>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(allMembers || []).map((member) => { {allMembers.map((member) => {
const isOnline = activeUserLogtoIds.has(member.logto_id); const isOnline = activeUserEmails.has(member.email);
const isCurrentUser = member.logto_id === appUser?.logtoId; const isCurrentUser = member.email === appUser?.email;
const hasPendingMessage = pendingMessagesByRecipient.has(member.email); const hasPendingMessage = pendingMessagesByRecipient.has(member.email);
return ( return (
@@ -176,13 +175,10 @@ export const TeamPresence: React.FC = () => {
onClose={handleModalClose} onClose={handleModalClose}
recipientId={selectedUser.id} recipientId={selectedUser.id}
recipientName={selectedUser.name} recipientName={selectedUser.name}
existingMessage={getExistingMessage(allMembers.find(m => m.logto_id === selectedUser.id)!)} existingMessage={getExistingMessage(allMembers.find(m => m.email === selectedUser.id)!)}
onMessageSent={handleMessageSent} onMessageSent={handleMessageSent}
/> />
)} )}
</> </>
); );
}; };
// No the component body I used useCallback so I need to import it
import { useCallback } from 'react';
@@ -36,6 +36,8 @@ export const BackupRestore: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = async () => { const handleExport = async () => {
if (!appUser) return;
setIsExporting(true); setIsExporting(true);
try { try {
const response = await api.get('/backup/export', { const response = await api.get('/backup/export', {
@@ -50,8 +52,7 @@ export const BackupRestore: React.FC = () => {
// Nome do arquivo com timestamp // Nome do arquivo com timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
const orgName = appUser?.name || 'GPI'; link.download = `backup_gpi_${timestamp}.json`;
link.download = `backup_${orgName}_${timestamp}.json`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
@@ -147,8 +148,8 @@ export const BackupRestore: React.FC = () => {
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-bold text-text-main mb-2">Backup e Restauração de Dados</h3> <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"> <p className="text-sm text-text-muted leading-relaxed">
Use esta ferramenta para criar cópias de segurança de todos os dados da organização ou restaurar dados de um backup anterior. 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 organização e não podem ser restaurados em outras organizações.</strong> <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> </p>
</div> </div>
</div> </div>
@@ -190,7 +191,7 @@ export const BackupRestore: React.FC = () => {
> >
{isExporting ? ( {isExporting ? (
<> <>
<RefreshCw size={20} className="animate-spin" /> <RefreshCw size={20} className="animate-spin" />
Gerando Backup... Gerando Backup...
</> </>
) : ( ) : (
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Box, RefreshCw } from 'lucide-react'; import { Plus, Pencil, Trash2, Box, RefreshCw } from 'lucide-react';
import { Button } from '../Button'; import { Button } from '../Button';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
@@ -8,7 +8,7 @@ import type { GeometryType } from '../../types';
import { useAuth } from '../../context/useAuth'; import { useAuth } from '../../context/useAuth';
export const GeometrySettings: React.FC = () => { export const GeometrySettings: React.FC = () => {
const { isSignedIn } = useAuth(); const { appUser } = useAuth();
const [types, setTypes] = useState<GeometryType[]>([]); const [types, setTypes] = useState<GeometryType[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -20,7 +20,7 @@ export const GeometrySettings: React.FC = () => {
efficiencyLoss: '20' efficiencyLoss: '20'
}); });
const fetchTypes = useCallback(async () => { const fetchTypes = React.useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const response = await geometryService.getAllTypes(); const response = await geometryService.getAllTypes();
@@ -33,15 +33,15 @@ export const GeometrySettings: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isSignedIn) { if (appUser) {
fetchTypes(); fetchTypes();
} }
}, [isSignedIn, fetchTypes]); }, [appUser, fetchTypes]);
const handleOpenModal = (item?: GeometryType) => { const handleOpenModal = (item?: GeometryType) => {
if (item) { if (item) {
setEditingItem(item); setEditingItem(item);
setForm({ name: item.name, efficiencyLoss: item.efficiencyLoss.toString() }); setForm({ name: item.name, efficiencyLoss: (item.efficiencyLoss ?? 0).toString() });
} else { } else {
setEditingItem(null); setEditingItem(null);
setForm({ name: '', efficiencyLoss: '20' }); setForm({ name: '', efficiencyLoss: '20' });
@@ -172,16 +172,16 @@ export const CreatePaintingSchemeModal: React.FC<CreatePaintingSchemeModalProps>
const payload = { const payload = {
...formData, ...formData,
projectId: selectedProjectId, projectId: selectedProjectId,
solidsVolume: formData.solidsVolume ? parseFloat(formData.solidsVolume) : null, solidsVolume: parseInt(formData.solidsVolume),
yieldTheoretical: formData.yieldTheoretical ? parseFloat(formData.yieldTheoretical) : null, yieldTheoretical: parseFloat(formData.yieldTheoretical),
epsMin: formData.epsMin ? parseFloat(formData.epsMin) : null, epsMin: parseFloat(formData.epsMin),
epsMax: formData.epsMax ? parseFloat(formData.epsMax) : null, epsMax: parseFloat(formData.epsMax),
dilution: formData.dilution ? parseFloat(formData.dilution) : null, dilution: parseInt(formData.dilution),
paintConsumption: formData.paintConsumption ? parseFloat(formData.paintConsumption) : null, paintConsumption: parseFloat(formData.paintConsumption),
thinnerConsumption: formData.thinnerConsumption ? parseFloat(formData.thinnerConsumption) : null, thinnerConsumption: parseFloat(formData.thinnerConsumption),
paintId: formData.paintId || null, paintId: formData.paintId || null,
thinnerId: formData.thinnerId || null, thinnerId: formData.thinnerId || null,
thinnerSymbol: formData.thinnerSymbol || null thinnerSymbol: formData.thinnerSymbol
}; };
if (initialData) { if (initialData) {
@@ -242,7 +242,7 @@ export const CreatePaintingSchemeModal: React.FC<CreatePaintingSchemeModalProps>
required required
/> />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <div className="grid grid-cols-2 gap-4">
<Select <Select
name="type" name="type"
label="Tipo de Tinta" label="Tipo de Tinta"
@@ -259,18 +259,18 @@ export const CreatePaintingSchemeModal: React.FC<CreatePaintingSchemeModalProps>
<Input name="solidsVolume" label="Sólidos Vol. (%)" type="number" value={formData.solidsVolume} onChange={handleChange} /> <Input name="solidsVolume" label="Sólidos Vol. (%)" type="number" value={formData.solidsVolume} onChange={handleChange} />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <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} /> <Input name="yieldTheoretical" label="Rendimento (m²/L)" type="number" step="0.01" value={formData.yieldTheoretical} onChange={handleChange} />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <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="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} /> <Input name="epsMax" label="EPS Máx (μm)" type="number" value={formData.epsMax} onChange={handleChange} />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <div className="grid grid-cols-2 gap-4">
<Input name="dilution" label="Diluição (%)" type="number" value={formData.dilution} onChange={handleChange} /> <Input name="dilution" label="Diluição (%)" type="number" value={formData.dilution} onChange={handleChange} />
<Input name="manufacturer" label="Fabricante" value={formData.manufacturer} onChange={handleChange} /> <Input name="manufacturer" label="Fabricante" value={formData.manufacturer} onChange={handleChange} />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <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} /> <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"> <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> <label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Cor Representativa</label>
@@ -299,9 +299,9 @@ export const CreatePaintingSchemeModal: React.FC<CreatePaintingSchemeModalProps>
/> />
</div> </div>
<div className="bg-surface-soft p-3.5 sm:p-4 rounded-xl border border-border/40 mt-4"> <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> <h3 className="text-sm font-bold text-text-main mb-3">Planejamento de Consumo (Opcional)</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <div className="grid grid-cols-2 gap-4">
<Select <Select
name="paintId" name="paintId"
label="Produto (Tinta)" label="Produto (Tinta)"
@@ -346,7 +346,7 @@ export const CreatePaintingSchemeModal: React.FC<CreatePaintingSchemeModalProps>
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 mt-2"> <div className="grid grid-cols-2 gap-4 mt-2">
<Input <Input
name="paintConsumption" name="paintConsumption"
label="Consumo Tinta (L/Kg)" label="Consumo Tinta (L/Kg)"
@@ -134,7 +134,7 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({ isOpen,
<Input name="name" label="Nome do Projeto" required value={formData.name} onChange={handleChange} /> <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="client" label="Cliente" required value={formData.client} onChange={handleChange} />
<Input name="technician" label="Responsável Técnico" value={formData.technician} onChange={handleChange} /> <Input name="technician" label="Responsável Técnico" value={formData.technician} onChange={handleChange} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <div className="grid grid-cols-2 gap-4">
<Input name="startDate" label="Início Planejado" type="date" value={formData.startDate} onChange={handleChange} /> <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} /> <Input name="endDate" label="Fim Planejado" type="date" value={formData.endDate} onChange={handleChange} />
</div> </div>
@@ -161,14 +161,14 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({ isOpen,
) : ( ) : (
<div className="space-y-6 text-center py-4"> <div className="space-y-6 text-center py-4">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-lg sm:text-xl font-bold text-text-main">Configurar Esquema de Pintura?</h3> <h3 className="text-xl font-bold text-text-main">Configurar Esquema de Pintura?</h3>
<p className="text-xs sm:text-sm text-text-muted px-2 sm:px-4">O projeto foi criado. Deseja adicionar um esquema de pintura agora?</p> <p className="text-sm text-text-muted px-4">O projeto foi criado. Deseja adicionar um esquema de pintura agora?</p>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <div className="grid grid-cols-2 gap-4">
<button <button
onClick={() => setShowImportModal(true)} onClick={() => setShowImportModal(true)}
className="flex flex-col items-center justify-center gap-3 p-4 sm:p-6 rounded-2xl border-2 border-dashed border-border hover:border-primary hover:bg-primary/5 transition-all group" 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"> <div className="p-3 rounded-full bg-surface-soft group-hover:bg-white text-primary transition-colors">
<FileDown size={24} /> <FileDown size={24} />
+4 -8
View File
@@ -48,10 +48,7 @@ export const StockModal: React.FC<StockModalProps> = ({ isOpen, onClose, onSucce
if (isOpen) { if (isOpen) {
fetchDataSheets(); fetchDataSheets();
if (initialData) { if (initialData) {
const dsId = (typeof initialData.dataSheetId === 'object') setDataSheetId(typeof initialData.dataSheetId === 'object' ? initialData.dataSheetId._id : initialData.dataSheetId);
? (initialData.dataSheetId.id || initialData.dataSheetId._id)
: initialData.dataSheetId;
setDataSheetId(dsId || '');
setRrNumber(initialData.rrNumber); setRrNumber(initialData.rrNumber);
setBatchNumber(initialData.batchNumber); setBatchNumber(initialData.batchNumber);
setColor(initialData.color || ''); setColor(initialData.color || '');
@@ -111,8 +108,7 @@ export const StockModal: React.FC<StockModalProps> = ({ isOpen, onClose, onSucce
try { try {
if (initialData) { if (initialData) {
const itemId = initialData.id || initialData._id; await stockService.update(initialData._id!, payload);
await stockService.update(itemId!, payload);
} else { } else {
await stockService.create(payload); await stockService.create(payload);
} }
@@ -151,12 +147,12 @@ export const StockModal: React.FC<StockModalProps> = ({ isOpen, onClose, onSucce
const val = e.target.value; const val = e.target.value;
setDataSheetId(val); setDataSheetId(val);
// Auto-fill minStock from DataSheet if set and current is empty/0 // Auto-fill minStock from DataSheet if set and current is empty/0
const ds = dataSheets.find(d => (d.id || d._id) === val); const ds = dataSheets.find(d => d._id === val);
if (ds && ds.minStock && (!minStock || minStock === '0')) { if (ds && ds.minStock && (!minStock || minStock === '0')) {
setMinStock(String(ds.minStock)); setMinStock(String(ds.minStock));
} }
}} }}
options={filteredDataSheets.map(ds => ({ label: `${ds.name} - ${ds.manufacturer}`, value: ds.id || ds._id }))} options={filteredDataSheets.map(ds => ({ label: `${ds.name} - ${ds.manufacturer}`, value: ds._id }))}
disabled={!!initialData} // Lock product on edit disabled={!!initialData} // Lock product on edit
/> />
+104 -51
View File
@@ -1,74 +1,127 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { AppUser } from '../types'; import type { AppUser } from '../types';
import { AuthContext } from './AuthContextType'; import { setApiToken, setApiOrganizationId, getBaseUrl } from '../services/api';
import { setApiOrganizationId } from '../services/api';
import api from '../services/api';
interface AuthProviderProps { const API_URL = getBaseUrl();
children: React.ReactNode;
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>;
} }
const DEFAULT_ORGANIZATION_ID = 'e47e6210-4879-4e5b-bf21-9285d2713123'; export const AuthContext = createContext<AuthContextType | undefined>(undefined);
const DEFAULT_ORGANIZATION_NAME = 'Organização Padrão';
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [appUser, setAppUser] = useState<AppUser | null>(null); const [appUser, setAppUser] = useState<AppUser | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('jwt_token'));
const [isLoading, setIsLoading] = useState(true); 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(() => { useEffect(() => {
const storedUser = localStorage.getItem('gpi_user'); if (token) {
if (storedUser) { setApiToken(token);
try { refetchUser();
setAppUser(JSON.parse(storedUser)); } else {
} catch (e) { setIsLoading(false);
console.error("Error parsing stored user", e);
}
} }
}, [token]);
const login = useCallback((newToken: string, user: AppUser) => {
localStorage.setItem('jwt_token', newToken);
setToken(newToken);
setAppUser(user);
setApiToken(newToken);
setApiOrganizationId(DEFAULT_ORGANIZATION_ID, DEFAULT_ORGANIZATION_NAME); // Se a organização existir, setar o header
setIsLoading(false); if (user.organizationId) {
setApiOrganizationId(user.organizationId);
}
}, []); }, []);
const signInWithPassword = async (password: string): Promise<boolean> => { 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 { try {
const response = await api.post('/users/login', { password }); const response = await fetch(`${API_URL}/auth/me`, {
if (response.data.success) { headers: {
const adminUser = response.data.user as AppUser; 'Authorization': `Bearer ${token}`
setAppUser(adminUser); },
localStorage.setItem('gpi_user', JSON.stringify(adminUser)); });
localStorage.setItem('gpi_token', response.data.token);
return true; if (response.ok) {
const userData = await response.json();
setAppUser(userData);
if (userData.organizationId) {
setApiOrganizationId(userData.organizationId);
}
} else {
// Token inválido ou expirado
logout();
} }
} catch (error) { } catch (err) {
console.error("Login failed:", error); console.error('Error refetching user:', err);
setError('Falha na comunicação de autenticação.');
} finally {
setIsLoading(false);
} }
return false; }, [token, logout]);
};
const isDeveloper = useCallback(() => appUser?.email === 'admtracksteel@gmail.com' || appUser?.id === 'admin-user', [appUser]); const isDeveloper = useCallback(() => {
const isAdmin = useCallback(() => appUser?.role === 'admin' || appUser?.email === 'admtracksteel@gmail.com', [appUser]); return appUser?.email === 'admtracksteel@gmail.com';
const isUser = useCallback(() => !!appUser, [appUser]); }, [appUser]);
const isGuest = useCallback(() => !appUser, [appUser]);
const canEdit = useCallback(() => isAdmin(), [isAdmin]);
const refetchUser = useCallback(async () => {}, []);
const value = useMemo(() => ({ const isAdmin = useCallback(() => appUser?.role === 'admin' || isDeveloper(), [appUser, isDeveloper]);
appUser, const isUser = useCallback(() => appUser?.role === 'user' || isAdmin(), [appUser, isAdmin]);
isLoading, const isGuest = useCallback(() => appUser?.role === 'guest' && !isDeveloper(), [appUser, isDeveloper]);
isSignedIn: !!appUser, const canEdit = useCallback(() => (appUser?.role !== 'guest' && appUser !== null) || isDeveloper(), [appUser, isDeveloper]);
error: null,
isAdmin,
isUser,
isGuest,
isDeveloper,
canEdit,
refetchUser,
signInWithPassword
}), [appUser, isLoading, isAdmin, isUser, isGuest, isDeveloper, canEdit, refetchUser]);
return ( return (
<AuthContext.Provider value={value}> <AuthContext.Provider
value={{
appUser,
isLoading,
isSignedIn: !!appUser,
error,
token,
login,
logout,
isAdmin,
isUser,
isGuest,
isDeveloper,
canEdit,
refetchUser,
}}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
-4
View File
@@ -1,4 +1,3 @@
import { createContext } from 'react';
import type { AppUser } from '../types'; import type { AppUser } from '../types';
export interface AuthContextType { export interface AuthContextType {
@@ -12,7 +11,4 @@ export interface AuthContextType {
isDeveloper: () => boolean; isDeveloper: () => boolean;
canEdit: () => boolean; canEdit: () => boolean;
refetchUser: () => Promise<void>; refetchUser: () => Promise<void>;
signInWithPassword: (password: string) => Promise<boolean>;
} }
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
+1 -10
View File
@@ -1,10 +1 @@
import { useContext } from 'react'; export { useAuth } from './AuthContext';
import { AuthContext } from './AuthContextType';
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
+23 -49
View File
@@ -5,57 +5,48 @@ import type { INotification } from '../types';
import { NotificationContext } from './NotificationContextState'; import { NotificationContext } from './NotificationContextState';
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isSignedIn } = useAuth(); const { appUser, isSignedIn } = useAuth();
const orgId = appUser?.organizationId;
const [notifications, setNotifications] = useState<INotification[]>([]); const [notifications, setNotifications] = useState<INotification[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fetchNotifications = useCallback(async () => { const fetchNotifications = useCallback(async () => {
if (!isSignedIn) return; if (!isSignedIn || !orgId) return;
try { try {
if (notifications.length === 0) setLoading(true); setLoading(true);
const response = await api.get('/notifications'); const response = await api.get<INotification[]>('/notifications');
setNotifications(response.data); setNotifications(response.data);
} catch (error) { } catch (error) {
console.error('Failed to fetch notifications', error); console.error('Error fetching notifications:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isSignedIn, notifications.length]); }, [isSignedIn, orgId]);
useEffect(() => {
if (isSignedIn && orgId) {
fetchNotifications();
const interval = setInterval(fetchNotifications, 60000);
return () => clearInterval(interval);
}
}, [isSignedIn, orgId, fetchNotifications]);
const markAsRead = async (id: string) => { const markAsRead = async (id: string) => {
try { try {
await api.put(`/notifications/${id}/read`); await api.patch(`/notifications/${id}/read`);
setNotifications(prev => prev.map(n => n._id === id ? { ...n, isRead: true } : n)); setNotifications(prev => prev.map(n => n._id === id ? { ...n, isRead: true } : n));
} catch (error) { } catch (error) {
console.error('Failed to mark as read', error); console.error('Error marking notification as read:', error);
} }
}; };
const markAllAsRead = async () => { const markAllAsRead = async () => {
try { try {
await api.put('/notifications/read-all'); await api.post('/notifications/read-all');
setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
} catch (error) { } catch (error) {
console.error('Failed to mark all as read', error); console.error('Error marking all notifications as read:', error);
}
}
const clearAll = async () => {
try {
await api.delete('/notifications/clear-all');
setNotifications([]);
} catch (error) {
console.error('Failed to clear all notifications', error);
}
};
const archiveNotification = async (id: string) => {
try {
await api.patch(`/notifications/${id}/archive`);
setNotifications(prev => prev.filter(n => n._id !== id));
} catch (error) {
console.error('Failed to archive notification', error);
} }
}; };
@@ -64,38 +55,21 @@ export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({
await api.delete(`/notifications/${id}`); await api.delete(`/notifications/${id}`);
setNotifications(prev => prev.filter(n => n._id !== id)); setNotifications(prev => prev.filter(n => n._id !== id));
} catch (error) { } catch (error) {
console.error('Failed to delete notification', error); console.error('Error deleting notification:', error);
} }
}; };
// Polling effect const unreadCount = notifications.filter(n => !n.isRead).length;
useEffect(() => {
if (isSignedIn) {
fetchNotifications(); // Initial fetch
const interval = setInterval(() => {
fetchNotifications();
}, 30000); // Poll every 30 seconds
return () => clearInterval(interval);
} else {
setNotifications([]);
}
}, [isSignedIn, fetchNotifications]);
const unreadCount = (notifications || []).filter(n => !n.isRead).length;
return ( return (
<NotificationContext.Provider value={{ <NotificationContext.Provider value={{
notifications, notifications,
unreadCount, unreadCount,
loading, loading,
fetchNotifications,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
clearAll, deleteNotification
archiveNotification,
deleteNotification,
fetchNotifications
}}> }}>
{children} {children}
</NotificationContext.Provider> </NotificationContext.Provider>
+2 -2
View File
@@ -3,10 +3,10 @@ import api from '../services/api';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
export interface ActiveUser { export interface ActiveUser {
id: string; _id: string;
name: string; name: string;
email: string; email: string;
logtoId: string; externalId: string;
lastSeenAt: string; lastSeenAt: string;
} }
+6 -18
View File
@@ -1,19 +1,7 @@
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import './index.css' import './index.css';
import App from './App.tsx' import App from './App.tsx';
export function getToken() { createRoot(document.getElementById('root')!).render(
return localStorage.getItem('gpi_token'); <App />
} );
export function getUser() {
const user = localStorage.getItem('gpi_user');
return user ? JSON.parse(user) : null;
}
export function setUser(token: string, user: any) {
localStorage.setItem('gpi_token', token);
localStorage.setItem('gpi_user', JSON.stringify(user));
}
createRoot(document.getElementById('root')!).render(<App />)
+63 -27
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; 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, Terminal } from 'lucide-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 { clsx } from 'clsx';
import type { AppUser, UserRole } from '../types'; import type { AppUser, UserRole } from '../types';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
@@ -14,7 +14,7 @@ const roleLabels: Record<UserRole, { label: string; color: string; icon: React.R
}; };
export const AdminDashboard: React.FC = () => { export const AdminDashboard: React.FC = () => {
const { isAdmin, appUser: currentUser } = useAuth(); const { appUser, isAdmin } = useAuth();
const [users, setUsers] = useState<AppUser[]>([]); const [users, setUsers] = useState<AppUser[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -24,6 +24,8 @@ export const AdminDashboard: React.FC = () => {
const [logoLoading, setLogoLoading] = useState(false); const [logoLoading, setLogoLoading] = useState(false);
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
if (!appUser) return;
try { try {
setIsLoading(true); setIsLoading(true);
const response = await api.get('/users'); const response = await api.get('/users');
@@ -33,15 +35,15 @@ export const AdminDashboard: React.FC = () => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, [appUser]);
useEffect(() => { useEffect(() => {
if (isAdmin()) { fetchUsers();
fetchUsers(); }, [fetchUsers]);
}
}, [isAdmin, fetchUsers]);
const handleRoleChange = async (userId: string, newRole: UserRole) => { const handleRoleChange = async (userId: string, newRole: UserRole) => {
if (!appUser) return;
setActionLoading(userId); setActionLoading(userId);
try { try {
const response = await api.patch(`/users/${userId}/role`, { role: newRole }); const response = await api.patch(`/users/${userId}/role`, { role: newRole });
@@ -57,6 +59,8 @@ export const AdminDashboard: React.FC = () => {
}; };
const handleToggleBan = async (userId: string, isBanned: boolean) => { const handleToggleBan = async (userId: string, isBanned: boolean) => {
if (!appUser) return;
setActionLoading(userId); setActionLoading(userId);
try { try {
const response = await api.patch(`/users/${userId}/ban`, { isBanned }); const response = await api.patch(`/users/${userId}/ban`, { isBanned });
@@ -79,8 +83,32 @@ export const AdminDashboard: React.FC = () => {
}); });
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
// Implement Logo Upload via Backend API if needed const file = e.target.files?.[0];
alert('Funcionalidade de upload de logo em migração para o novo sistema.'); 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()) { if (!isAdmin()) {
@@ -121,11 +149,11 @@ export const AdminDashboard: React.FC = () => {
</div> </div>
{/* Tabs Navigation */} {/* Tabs Navigation */}
<div className="flex p-1 bg-surface-soft rounded-xl border border-border/40 overflow-x-auto max-w-full scrollbar-hide"> <div className="flex p-1 bg-surface-soft rounded-xl border border-border/40 w-fit">
<button <button
onClick={() => setActiveTab('users')} onClick={() => setActiveTab('users')}
className={clsx( className={clsx(
"flex items-center gap-2 px-4 sm:px-6 py-2 sm:py-2.5 text-xs sm:text-sm font-bold rounded-lg transition-all shrink-0", "flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
activeTab === 'users' activeTab === 'users'
? "bg-surface text-primary shadow-sm border border-border/40" ? "bg-surface text-primary shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover" : "text-text-muted hover:text-text-main hover:bg-surface-hover"
@@ -137,7 +165,7 @@ export const AdminDashboard: React.FC = () => {
<button <button
onClick={() => setActiveTab('organization')} onClick={() => setActiveTab('organization')}
className={clsx( className={clsx(
"flex items-center gap-2 px-4 sm:px-6 py-2 sm:py-2.5 text-xs sm:text-sm font-bold rounded-lg transition-all shrink-0", "flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
activeTab === 'organization' activeTab === 'organization'
? "bg-surface text-primary shadow-sm border border-border/40" ? "bg-surface text-primary shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover" : "text-text-muted hover:text-text-main hover:bg-surface-hover"
@@ -149,7 +177,7 @@ export const AdminDashboard: React.FC = () => {
<button <button
onClick={() => setActiveTab('settings')} onClick={() => setActiveTab('settings')}
className={clsx( className={clsx(
"flex items-center gap-2 px-4 sm:px-6 py-2 sm:py-2.5 text-xs sm:text-sm font-bold rounded-lg transition-all shrink-0", "flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
activeTab === 'settings' activeTab === 'settings'
? "bg-surface text-primary shadow-sm border border-border/40" ? "bg-surface text-primary shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover" : "text-text-muted hover:text-text-main hover:bg-surface-hover"
@@ -161,7 +189,7 @@ export const AdminDashboard: React.FC = () => {
<button <button
onClick={() => setActiveTab('backup')} onClick={() => setActiveTab('backup')}
className={clsx( className={clsx(
"flex items-center gap-2 px-4 sm:px-6 py-2 sm:py-2.5 text-xs sm:text-sm font-bold rounded-lg transition-all shrink-0", "flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
activeTab === 'backup' activeTab === 'backup'
? "bg-surface text-primary shadow-sm border border-border/40" ? "bg-surface text-primary shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover" : "text-text-muted hover:text-text-main hover:bg-surface-hover"
@@ -273,7 +301,7 @@ export const AdminDashboard: React.FC = () => {
<tbody className="divide-y divide-border/40"> <tbody className="divide-y divide-border/40">
{filteredUsers.map((u) => { {filteredUsers.map((u) => {
const roleInfo = roleLabels[u.role]; const roleInfo = roleLabels[u.role];
const isCurrentUser = u.email === currentUser?.email; const isCurrentUser = u.email === appUser?.email;
const isActionDisabled = actionLoading === u.id; const isActionDisabled = actionLoading === u.id;
return ( return (
@@ -349,24 +377,32 @@ export const AdminDashboard: React.FC = () => {
</> </>
) : activeTab === 'organization' ? ( ) : activeTab === 'organization' ? (
<div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-300"> <div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-300">
<div className="bg-surface rounded-2xl p-6 border border-border/40 space-y-6"> <div className="bg-surface rounded-2xl p-8 border border-border/40 text-center space-y-4">
<div className="flex items-center gap-3 pb-4 border-b border-border/20"> <ImageIcon size={48} className="mx-auto text-text-muted opacity-20" />
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center"> <h2 className="text-xl font-bold text-text-main">Gestão de Identidade Visual</h2>
<Info size={20} className="text-primary" /> <p className="text-text-muted max-w-md mx-auto">
</div> O gerenciamento nativo de logos está sendo implementado. No momento, o logo atual é gerenciado via configurações do sistema.
<div> </p>
<h2 className="text-lg font-bold text-text-main">Configurações da Organização</h2>
<p className="text-xs text-text-muted">Migrando para o novo sistema Logto</p>
</div>
</div>
<p className="text-text-muted">A gestão de identidade visual e dados da organização está sendo migrada para a API central.</p>
</div> </div>
</div> </div>
) : activeTab === 'settings' ? ( ) : activeTab === 'settings' ? (
<GeometrySettings /> <GeometrySettings />
) : activeTab === 'backup' ? ( ) : activeTab === 'backup' ? (
<BackupRestore /> <BackupRestore />
) : null} ) : (
<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> </div>
); );
}; };
+9 -9
View File
@@ -181,20 +181,20 @@ export const CalculatorDashboard: React.FC = () => {
}; };
return ( return (
<div className="space-y-6 sm:space-y-8 animate-in fade-in duration-700"> <div className="space-y-8 animate-in fade-in duration-700">
{/* Header */} {/* Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 md:gap-6 pb-2"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-2"> <div className="space-y-4">
<div> <div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">Ferramentas & Cálculos</h1> <h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">Ferramentas & Cálculos</h1>
<p className="text-xs sm:text-sm text-text-muted font-medium tracking-wide">Utilitários técnicos para pintura industrial</p> <p className="text-sm text-text-muted font-medium tracking-wide">Utilitários técnicos para pintura industrial</p>
</div> </div>
</div> </div>
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex items-center justify-between border-b border-border/40 scrollbar-hide overflow-x-auto"> <div className="flex items-center justify-between border-b border-border/40 scrollbar-hide overflow-x-auto">
<nav className="flex space-x-3 sm:space-x-6 md:space-x-10 min-w-max px-1" aria-label="Tabs"> <nav className="flex space-x-10 min-w-max px-2" aria-label="Tabs">
{[ {[
{ id: 'conversion', label: 'Conversões', icon: ArrowRightLeft }, { id: 'conversion', label: 'Conversões', icon: ArrowRightLeft },
{ id: 'thickness', label: 'Espessura', icon: Ruler }, { id: 'thickness', label: 'Espessura', icon: Ruler },
@@ -209,7 +209,7 @@ export const CalculatorDashboard: React.FC = () => {
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={clsx( className={clsx(
'whitespace-nowrap py-3.5 sm:py-5 px-1 border-b-2 font-bold text-[11px] sm:text-xs uppercase tracking-[0.15em] flex items-center gap-2 sm:gap-3 transition-all relative', 'whitespace-nowrap py-5 px-1 border-b-2 font-bold text-xs uppercase tracking-[0.15em] flex items-center gap-3 transition-all relative',
active active
? 'border-primary text-primary' ? 'border-primary text-primary'
: 'border-transparent text-text-muted hover:text-text-main hover:border-border' : 'border-transparent text-text-muted hover:text-text-main hover:border-border'
@@ -230,8 +230,8 @@ export const CalculatorDashboard: React.FC = () => {
{/* 1. CONVERSION */} {/* 1. CONVERSION */}
{activeTab === 'conversion' && ( {activeTab === 'conversion' && (
<div className="max-w-2xl mx-auto space-y-6 animate-in slide-in-from-bottom-4 duration-500"> <div className="max-w-2xl mx-auto space-y-6 animate-in slide-in-from-bottom-4 duration-500">
<Card className="p-4 sm:p-6 md:p-8 space-y-6 sm:space-y-8 bg-surface border-border/40 shadow-soft"> <Card className="p-8 space-y-8 bg-surface border-border/40 shadow-soft">
<div className="flex flex-col gap-1 sm:gap-2 border-b border-border/40 pb-4 sm:pb-6"> <div className="flex flex-col gap-2 border-b border-border/40 pb-6">
<h3 className="text-xl font-bold text-text-main">Conversor de Unidades</h3> <h3 className="text-xl font-bold text-text-main">Conversor de Unidades</h3>
<p className="text-sm text-text-muted">Microns (μm) Milesimos de Polegada (mils)</p> <p className="text-sm text-text-muted">Microns (μm) Milesimos de Polegada (mils)</p>
</div> </div>
-23
View File
@@ -1,23 +0,0 @@
import { useHandleSignInCallback } from '@logto/react';
import { useNavigate } from 'react-router-dom';
export const Callback = () => {
const navigate = useNavigate();
const { isLoading } = useHandleSignInCallback(() => {
// Redireciona para a home após o login bem-sucedido
navigate('/');
});
if (isLoading) {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-surface-soft">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-text-muted font-medium">Finalizando autenticação...</p>
</div>
</div>
);
}
return null;
};
+22 -26
View File
@@ -24,7 +24,6 @@ export const DeveloperDashboard: React.FC = () => {
const [appName, setAppName] = useState(''); const [appName, setAppName] = useState('');
const [appSubtitle, setAppSubtitle] = useState(''); const [appSubtitle, setAppSubtitle] = useState('');
const [logoUrl, setLogoUrl] = useState(''); const [logoUrl, setLogoUrl] = useState('');
const [logoError, setLogoError] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -70,7 +69,6 @@ export const DeveloperDashboard: React.FC = () => {
setAppName(settings.appName); setAppName(settings.appName);
setAppSubtitle(settings.appSubtitle); setAppSubtitle(settings.appSubtitle);
setLogoUrl(settings.appLogoUrl || ''); setLogoUrl(settings.appLogoUrl || '');
setLogoError(false);
} }
}, [settings]); }, [settings]);
@@ -141,11 +139,11 @@ export const DeveloperDashboard: React.FC = () => {
</div> </div>
{/* Tabs Navigation */} {/* Tabs Navigation */}
<div className="flex items-center gap-1.5 sm:gap-2 p-1 bg-surface-soft rounded-2xl border border-border/20 overflow-x-auto max-w-full scrollbar-hide"> <div className="flex items-center gap-2 p-1 bg-surface-soft rounded-2xl border border-border/20 w-fit">
<button <button
onClick={() => setActiveTab('overview')} onClick={() => setActiveTab('overview')}
className={clsx( className={clsx(
"flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 text-xs sm:text-sm font-bold rounded-xl transition-all shrink-0", "flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
activeTab === 'overview' activeTab === 'overview'
? "bg-surface text-indigo-500 shadow-sm border border-border/40" ? "bg-surface text-indigo-500 shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover" : "text-text-muted hover:text-text-main hover:bg-surface-hover"
@@ -157,7 +155,7 @@ export const DeveloperDashboard: React.FC = () => {
<button <button
onClick={() => setActiveTab('organizations')} onClick={() => setActiveTab('organizations')}
className={clsx( className={clsx(
"flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 text-xs sm:text-sm font-bold rounded-xl transition-all shrink-0", "flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
activeTab === 'organizations' activeTab === 'organizations'
? "bg-surface text-indigo-500 shadow-sm border border-border/40" ? "bg-surface text-indigo-500 shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover" : "text-text-muted hover:text-text-main hover:bg-surface-hover"
@@ -169,7 +167,7 @@ export const DeveloperDashboard: React.FC = () => {
<button <button
onClick={() => setActiveTab('users')} onClick={() => setActiveTab('users')}
className={clsx( className={clsx(
"flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 text-xs sm:text-sm font-bold rounded-xl transition-all shrink-0", "flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
activeTab === 'users' activeTab === 'users'
? "bg-surface text-indigo-500 shadow-sm border border-border/40" ? "bg-surface text-indigo-500 shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover" : "text-text-muted hover:text-text-main hover:bg-surface-hover"
@@ -181,7 +179,7 @@ export const DeveloperDashboard: React.FC = () => {
<button <button
onClick={() => setActiveTab('branding')} onClick={() => setActiveTab('branding')}
className={clsx( className={clsx(
"flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 text-xs sm:text-sm font-bold rounded-xl transition-all shrink-0", "flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
activeTab === 'branding' activeTab === 'branding'
? "bg-surface text-indigo-500 shadow-sm border border-border/40" ? "bg-surface text-indigo-500 shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover" : "text-text-muted hover:text-text-main hover:bg-surface-hover"
@@ -193,7 +191,7 @@ export const DeveloperDashboard: React.FC = () => {
</div> </div>
{/* Content Area */} {/* Content Area */}
<div className="bg-surface rounded-2xl sm:rounded-3xl border border-border/40 min-h-[450px] overflow-hidden"> <div className="bg-surface rounded-3xl border border-border/40 min-h-[500px] overflow-hidden">
{activeTab === 'overview' && ( {activeTab === 'overview' && (
<div className="p-12 flex flex-col items-center justify-center text-center h-full"> <div className="p-12 flex flex-col items-center justify-center text-center h-full">
<div className="w-16 h-16 rounded-2xl bg-indigo-500/10 flex items-center justify-center mb-6 text-indigo-500 ring-1 ring-indigo-500/20"> <div className="w-16 h-16 rounded-2xl bg-indigo-500/10 flex items-center justify-center mb-6 text-indigo-500 ring-1 ring-indigo-500/20">
@@ -300,8 +298,8 @@ export const DeveloperDashboard: React.FC = () => {
<span className="text-xs font-bold text-text-muted uppercase tracking-wider mb-2 block">Preview</span> <span className="text-xs font-bold text-text-muted uppercase tracking-wider mb-2 block">Preview</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shrink-0 overflow-hidden shadow-lg"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shrink-0 overflow-hidden shadow-lg">
{logoUrl && !logoError ? ( {logoUrl ? (
<img src={logoUrl} alt="Preview" onError={() => setLogoError(true)} className="w-full h-full object-cover" /> <img src={logoUrl} alt="Preview" className="w-full h-full object-cover" />
) : ( ) : (
<span className="text-white font-black text-xl">{appName?.[0] || 'G'}</span> <span className="text-white font-black text-xl">{appName?.[0] || 'G'}</span>
)} )}
@@ -348,12 +346,11 @@ export const DeveloperDashboard: React.FC = () => {
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{globalOrgs.map((org) => { {globalOrgs.map((org) => {
const membersList = org.members || []; const admins = org.members.filter(m => m.role === 'admin');
const admins = membersList.filter(m => m.role === 'admin'); const commonUsers = org.members.filter(m => m.role !== 'admin');
const commonUsers = membersList.filter(m => m.role !== 'admin');
return ( return (
<div key={org._id || org.id} className={clsx( <div key={org._id} className={clsx(
"bg-surface-soft border rounded-xl overflow-hidden transition-all", "bg-surface-soft border rounded-xl overflow-hidden transition-all",
org.isBanned org.isBanned
? "border-red-500/50 shadow-[0_0_15px_rgba(239,68,68,0.1)]" ? "border-red-500/50 shadow-[0_0_15px_rgba(239,68,68,0.1)]"
@@ -375,21 +372,20 @@ export const DeveloperDashboard: React.FC = () => {
)} )}
</div> </div>
<div className="font-bold text-text-main text-base mb-0.5">{org.name || 'Organização Sem Nome'}</div> <div className="font-bold text-text-main text-base mb-0.5">{org.name || 'Organização Sem Nome'}</div>
<div className="font-mono text-indigo-400/80 text-xs select-all" title="ID da Organização">{org._id || org.id}</div> <div className="font-mono text-indigo-400/80 text-xs select-all" title="ID da Organização">{org._id}</div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-right"> <div className="text-right">
<div className="text-xs font-bold text-text-muted uppercase tracking-wider mb-1">Total de Membros</div> <div className="text-xs font-bold text-text-muted uppercase tracking-wider mb-1">Total de Membros</div>
<span className="bg-surface-hover px-2 py-1 rounded text-sm font-bold">{org.memberCount || membersList.length}</span> <span className="bg-surface-hover px-2 py-1 rounded text-sm font-bold">{org.memberCount}</span>
</div> </div>
<button <button
onClick={async () => { onClick={async () => {
const orgTargetId = org._id || org.id || '';
if (!orgTargetId) return;
if (confirm(org.isBanned ? 'Deseja desbloquear esta organização?' : 'Deseja BLOQUEAR esta organização? Os usuários perderão o acesso imediatamente.')) { if (confirm(org.isBanned ? 'Deseja desbloquear esta organização?' : 'Deseja BLOQUEAR esta organização? Os usuários perderão o acesso imediatamente.')) {
try { try {
await systemSettingsService.toggleOrganizationBan(orgTargetId, !org.isBanned); await systemSettingsService.toggleOrganizationBan(org._id, !org.isBanned);
showToast(`Organização ${org.isBanned ? 'desbloqueada' : 'bloqueada'} com sucesso!`, 'success'); showToast(`Organização ${org.isBanned ? 'desbloqueada' : 'bloqueada'} com sucesso!`, 'success');
// Refresh list
const data = await systemSettingsService.getGlobalOrganizations(); const data = await systemSettingsService.getGlobalOrganizations();
setGlobalOrgs(data); setGlobalOrgs(data);
} catch (e) { } catch (e) {
@@ -407,7 +403,7 @@ export const DeveloperDashboard: React.FC = () => {
title={org.isBanned ? "Desbloquear Organização" : "Bloquear Organização"} title={org.isBanned ? "Desbloquear Organização" : "Bloquear Organização"}
> >
{org.isBanned ? ( {org.isBanned ? (
<Users size={18} /> <Users size={18} /> // Unblock icon
) : ( ) : (
<X size={18} /> <X size={18} />
)} )}
@@ -423,12 +419,12 @@ export const DeveloperDashboard: React.FC = () => {
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{admins.map(admin => ( {admins.map(admin => (
<div key={admin.userId || admin.email} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-indigo-500/20"> <div key={admin.userId} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-indigo-500/20">
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-500 font-bold text-xs"> <div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-500 font-bold text-xs">
{(admin.name || 'A').charAt(0).toUpperCase()} {admin.name.charAt(0).toUpperCase()}
</div> </div>
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="font-bold text-sm truncate text-text-main">{admin.name || 'Administrador'}</div> <div className="font-bold text-sm truncate text-text-main">{admin.name}</div>
<div className="text-[10px] text-text-muted truncate">{admin.email}</div> <div className="text-[10px] text-text-muted truncate">{admin.email}</div>
</div> </div>
{admin.isBanned && <span className="ml-auto text-error text-[10px] uppercase font-bold">Banido</span>} {admin.isBanned && <span className="ml-auto text-error text-[10px] uppercase font-bold">Banido</span>}
@@ -445,15 +441,15 @@ export const DeveloperDashboard: React.FC = () => {
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{commonUsers.map(user => ( {commonUsers.map(user => (
<div key={user.userId || user.email} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-border/40"> <div key={user.userId} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-border/40">
<div className={clsx( <div className={clsx(
"w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs", "w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs",
user.role === 'user' ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500" user.role === 'user' ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500"
)}> )}>
{(user.name || 'U').charAt(0).toUpperCase()} {user.name.charAt(0).toUpperCase()}
</div> </div>
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="font-bold text-sm truncate text-text-main">{user.name || 'Usuário'}</div> <div className="font-bold text-sm truncate text-text-main">{user.name}</div>
<div className="text-[10px] text-text-muted truncate">{user.email}</div> <div className="text-[10px] text-text-muted truncate">{user.email}</div>
</div> </div>
<span className={clsx( <span className={clsx(
+71 -49
View File
@@ -1,30 +1,41 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { Hammer, Lock, ShieldCheck } from "lucide-react"; import { Hammer } from "lucide-react";
import { useAuth } from '../context/useAuth'; import { useAuth } from "../context/useAuth";
import { useNavigate } from 'react-router-dom'; import { getBaseUrl } from "../services/api";
const API_URL = getBaseUrl();
export const Login = () => { export const Login = () => {
const [password, setPassword] = useState(''); const [email, setEmail] = useState("");
const [error, setError] = useState(''); const [password, setPassword] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { signInWithPassword } = useAuth();
const navigate = useNavigate(); const { login } = useAuth();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setErrorMsg("");
setLoading(true); setLoading(true);
try { try {
const success = await signInWithPassword(password); const response = await fetch(`${API_URL}/auth/login`, {
if (success) { method: "POST",
navigate('/'); headers: { "Content-Type": "application/json" },
} else { body: JSON.stringify({ email, password })
setError('Senha incorreta. Acesso negado.'); });
const data = await response.json();
if (!response.ok) {
setErrorMsg(data.error || "Erro ao efetuar login");
setLoading(false);
return;
} }
login(data.token, data.user);
} catch (err) { } catch (err) {
setError('Erro ao processar login.'); setErrorMsg("Falha na conexão com o servidor.");
} finally {
setLoading(false); setLoading(false);
} }
}; };
@@ -38,54 +49,65 @@ export const Login = () => {
<div className="relative z-10 w-full max-w-md px-6 flex flex-col items-center"> <div className="relative z-10 w-full max-w-md px-6 flex flex-col items-center">
{/* Logo Area */} {/* Logo Area */}
<div className="mb-8 flex flex-col items-center text-center"> <div className="mb-8 flex flex-col items-center text-center">
<div className="w-16 h-16 rounded-2xl bg-primary flex items-center justify-center text-white font-bold text-3xl shadow-2xl shadow-primary/40 mb-4"> <div className="w-16 h-16 rounded-2xl bg-primary flex items-center justify-center text-white font-bold text-3xl shadow-2xl shadow-primary/40 mb-4 animate-in zoom-in duration-700">
G G
</div> </div>
<h1 className="text-3xl font-bold text-text-main tracking-tight mb-1">GPI RESTRICT</h1> <h1 className="text-3xl font-bold text-text-main tracking-tight mb-1">GPI</h1>
<p className="text-text-muted text-[10px] font-black uppercase tracking-[0.3em]">Ambiente de Desenvolvimento</p> <p className="text-text-muted text-sm font-medium uppercase tracking-widest">Gestão de Pintura Industrial</p>
</div> </div>
{/* Login Form */} {/* Custom Login Form */}
<div className="w-full bg-surface rounded-[2.5rem] border border-border/40 shadow-2xl shadow-primary/5 p-10 backdrop-blur-sm"> <div className="w-full bg-surface rounded-[2rem] border border-border/40 shadow-2xl shadow-primary/5 p-8 animate-in slide-in-from-bottom-8 duration-1000">
<div className="flex items-center gap-3 mb-8 text-primary"> <h2 className="text-xl font-bold text-text-main mb-6 text-center">Entrar na sua conta</h2>
<Lock size={20} className="opacity-70" />
<h2 className="text-lg font-bold uppercase tracking-tight">Chave de Acesso</h2> {errorMsg && (
</div> <div className="mb-4 p-3 rounded-lg bg-error/10 border border-error/20 text-error text-sm text-center">
{errorMsg}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-2"> <div className="flex flex-col gap-2">
<input <label className="text-sm font-semibold text-text-secondary" htmlFor="email">Email</label>
type="password" <input
placeholder="Digite a senha mestra..." id="email"
value={password} type="email"
onChange={(e) => setPassword(e.target.value)}
className="w-full h-14 bg-surface-soft border border-border/40 rounded-2xl px-6 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-bold placeholder:font-medium tracking-widest text-center"
required required
autoFocus value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-surface-soft border border-border/40 focus:ring-2 focus:ring-primary/20 focus:border-primary rounded-xl px-4 py-3 text-text-main outline-none transition-all"
placeholder="seu@email.com"
/> />
{error && <p className="text-error text-[10px] font-bold uppercase text-center mt-2 tracking-wider">{error}</p>}
</div> </div>
<button <div className="flex flex-col gap-2">
type="submit" <div className="flex justify-between items-center">
<label className="text-sm font-semibold text-text-secondary" htmlFor="password">Senha</label>
</div>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-surface-soft border border-border/40 focus:ring-2 focus:ring-primary/20 focus:border-primary rounded-xl px-4 py-3 text-text-main outline-none transition-all"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading} disabled={loading}
className="w-full h-14 bg-primary hover:bg-primary/90 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-primary/20 flex items-center justify-center gap-3 active:scale-95 disabled:opacity-50" className="mt-4 bg-primary hover:bg-primary/90 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-primary/20 disabled:opacity-70 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? "Entrando..." : "Entrar"}
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<ShieldCheck size={20} />
Entrar no Sistema
</>
)}
</button> </button>
</form> </form>
</div> </div>
<div className="mt-8 flex items-center gap-2 text-text-muted/60 text-[10px] font-black uppercase tracking-widest"> <div className="mt-8 flex items-center gap-2 text-text-muted/60 text-xs font-medium">
<Hammer size={12} /> <Hammer size={14} />
<span>Desenvolvimento Ativo</span> <span>© 2026 GPI - Eficiência Industrial</span>
</div> </div>
</div> </div>
</div> </div>
+49
View File
@@ -0,0 +1,49 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/useAuth';
import { Building2, RefreshCw } from 'lucide-react';
export const OrganizationSelector: React.FC = () => {
const { appUser, isSignedIn } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (isSignedIn && appUser?.organizationId) {
navigate('/');
}
}, [isSignedIn, appUser, navigate]);
if (!isSignedIn) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl border border-border/40 p-8 text-center">
<div className="w-16 h-16 rounded-2xl bg-amber-500/20 flex items-center justify-center mx-auto mb-4">
<Building2 className="w-8 h-8 text-amber-500" />
</div>
<h1 className="text-2xl font-bold text-text-main mb-2">
Não Conectado
</h1>
<p className="text-text-muted mb-6">
Você precisa estar logado para acessar esta página.
</p>
<button
onClick={() => navigate('/login')}
className="w-full py-3 bg-primary text-white rounded-xl font-bold"
>
Ir para Login
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<RefreshCw className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
<p className="text-text-main font-bold mb-2">Redirecionando...</p>
<p className="text-text-muted text-sm">Carregando sua organização</p>
</div>
</div>
);
};
+20 -20
View File
@@ -201,17 +201,17 @@ export const ProjectDetails: React.FC = () => {
return ( return (
<div className="space-y-8 animate-in fade-in duration-700"> <div className="space-y-8 animate-in fade-in duration-700">
{/* Page Header */} {/* Page Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 md:gap-6 pb-2"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-3 sm:space-y-4"> <div className="space-y-4">
<Link to="/projects"> <Link to="/projects">
<button className="flex items-center text-[10px] font-bold text-text-muted uppercase tracking-[0.2em] hover:text-primary transition-colors group"> <button className="flex items-center text-[10px] font-bold text-text-muted uppercase tracking-[0.2em] hover:text-primary transition-colors group">
<ArrowLeft className="w-3 h-3 mr-2 group-hover:-translate-x-1 transition-transform" /> <ArrowLeft className="w-3 h-3 mr-2 group-hover:-translate-x-1 transition-transform" />
Voltar aos Projetos Voltar aos Projetos
</button> </button>
</Link> </Link>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4"> <div className="flex flex-col sm:flex-row sm:items-center gap-4">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">{project.name}</h1> <h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">{project.name}</h1>
<span className="inline-flex items-center w-fit px-3 py-1 rounded-full bg-primary/10 text-primary text-[10px] font-black uppercase tracking-widest border border-primary/20 shadow-sm shadow-primary/5"> <span className="inline-flex items-center px-3 py-1 rounded-full bg-primary/10 text-primary text-[10px] font-black uppercase tracking-widest border border-primary/20 shadow-sm shadow-primary/5">
<Activity className="w-3 h-3 mr-2" /> <Activity className="w-3 h-3 mr-2" />
Em Andamento Em Andamento
</span> </span>
@@ -221,12 +221,12 @@ export const ProjectDetails: React.FC = () => {
<div className="w-6 h-6 rounded-full bg-surface-highlight border-2 border-surface flex items-center justify-center text-[10px] font-bold">M</div> <div className="w-6 h-6 rounded-full bg-surface-highlight border-2 border-surface flex items-center justify-center text-[10px] font-bold">M</div>
<div className="w-6 h-6 rounded-full bg-primary/20 border-2 border-surface flex items-center justify-center text-[10px] font-bold text-primary">G</div> <div className="w-6 h-6 rounded-full bg-primary/20 border-2 border-surface flex items-center justify-center text-[10px] font-bold text-primary">G</div>
</div> </div>
<span className="text-xs sm:text-sm text-text-secondary font-medium">{project.client} {project.environment}</span> <span className="text-sm text-text-secondary font-medium">{project.client} {project.environment}</span>
</div> </div>
</div> </div>
{isAdmin() && ( {isAdmin() && (
<div className="flex flex-wrap sm:flex-nowrap gap-2 sm:gap-3"> <div className="flex gap-3">
<Button variant="secondary" size="sm" onClick={() => setIsEditProjectModalOpen(true)}> <Button variant="secondary" size="sm" onClick={() => setIsEditProjectModalOpen(true)}>
<Pencil className="w-4 h-4 mr-2" /> Editar Obra <Pencil className="w-4 h-4 mr-2" /> Editar Obra
</Button> </Button>
@@ -238,45 +238,45 @@ export const ProjectDetails: React.FC = () => {
</div> </div>
{/* Premium Summary Grid */} {/* Premium Summary Grid */}
<div className="relative overflow-hidden rounded-2xl sm:rounded-3xl bg-surface border border-border/40 shadow-soft p-4 sm:p-6 md:p-8"> <div className="relative overflow-hidden rounded-3xl bg-surface border border-border/40 shadow-soft p-8">
<div className="absolute top-0 right-0 p-8 opacity-5"> <div className="absolute top-0 right-0 p-8 opacity-5">
<Activity size={120} strokeWidth={1} /> <Activity size={120} strokeWidth={1} />
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6 md:gap-8 relative z-10"> <div className="grid grid-cols-2 md:grid-cols-4 gap-8 relative z-10">
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Data de Início</span> <span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Data de Início</span>
<p className="text-sm sm:text-base md:text-lg font-bold text-text-main">{project.startDate ? format(new Date(project.startDate), 'dd MMM, yyyy') : '-'}</p> <p className="text-lg font-bold text-text-main">{project.startDate ? format(new Date(project.startDate), 'dd MMM, yyyy') : '-'}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Previsão Término</span> <span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Previsão Término</span>
<p className="text-sm sm:text-base md:text-lg font-bold text-text-main">{project.endDate ? format(new Date(project.endDate), 'dd MMM, yyyy') : '-'}</p> <p className="text-lg font-bold text-text-main">{project.endDate ? format(new Date(project.endDate), 'dd MMM, yyyy') : '-'}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Peso da Obra (KGF)</span> <span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Peso da Obra (KGF)</span>
<p className="text-sm sm:text-base md:text-lg font-bold text-text-main"> <p className="text-lg font-bold text-text-main">
{project.weightKg ? Number(project.weightKg).toLocaleString('pt-BR') : '0'} <span className="text-xs sm:text-sm text-text-muted">kg</span> {project.weightKg ? Number(project.weightKg).toLocaleString('pt-BR') : '0'} <span className="text-sm text-text-muted">kg</span>
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Evolução%</span> <span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Evolução%</span>
<p className="text-sm sm:text-base md:text-lg font-bold text-primary"> <p className="text-lg font-bold text-primary">
{project.weightKg && project.paintedWeight !== undefined {project.weightKg && project.paintedWeight !== undefined
? Math.min(Math.round((project.paintedWeight / project.weightKg) * 100), 100) ? Math.min(Math.round((project.paintedWeight / project.weightKg) * 100), 100)
: 0} : 0}
<span className="text-xs sm:text-sm">%</span> <span className="text-sm">%</span>
</p> </p>
</div> </div>
</div> </div>
<div className="mt-4 sm:mt-6 pt-4 sm:pt-6 border-t border-border/20 grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 relative z-10"> <div className="mt-6 pt-6 border-t border-border/20 grid grid-cols-1 md:grid-cols-2 gap-6 relative z-10">
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Localização/Amb.</span> <span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Localização/Amb.</span>
<p className="text-xs sm:text-sm font-medium text-text-secondary">{project.environment || '-'}</p> <p className="text-sm font-medium text-text-secondary">{project.environment || '-'}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Eng. Responsável</span> <span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Eng. Responsável</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500"></div> <div className="w-2 h-2 rounded-full bg-blue-500"></div>
<p className="text-xs sm:text-sm font-bold text-text-main">{project.technician || '-'}</p> <p className="text-sm font-bold text-text-main">{project.technician || '-'}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -284,7 +284,7 @@ export const ProjectDetails: React.FC = () => {
{/* Navigation Tabs */} {/* Navigation Tabs */}
<div className="flex items-center justify-between border-b border-border/40 scrollbar-hide overflow-x-auto"> <div className="flex items-center justify-between border-b border-border/40 scrollbar-hide overflow-x-auto">
<nav className="flex space-x-4 sm:space-x-8 md:space-x-10 min-w-max px-1" aria-label="Tabs"> <nav className="flex space-x-10" aria-label="Tabs">
{tabs.map((tab) => { {tabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
const active = activeTab === tab.id; const active = activeTab === tab.id;
@@ -293,7 +293,7 @@ export const ProjectDetails: React.FC = () => {
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={clsx( className={clsx(
'whitespace-nowrap py-3.5 sm:py-5 px-1 border-b-2 font-bold text-[11px] sm:text-xs uppercase tracking-[0.15em] flex items-center gap-2 sm:gap-3 transition-all relative', 'whitespace-nowrap py-5 px-1 border-b-2 font-bold text-xs uppercase tracking-[0.15em] flex items-center gap-3 transition-all relative',
active active
? 'border-primary text-primary' ? 'border-primary text-primary'
: 'border-transparent text-text-muted hover:text-text-main hover:border-border' : 'border-transparent text-text-muted hover:text-text-main hover:border-border'
+20 -19
View File
@@ -49,12 +49,13 @@ export const ProjectList: React.FC = () => {
const [isPrintingGeneral, setIsPrintingGeneral] = useState(false); const [isPrintingGeneral, setIsPrintingGeneral] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { appUser, isAdmin: checkIsAdmin } = useAuth(); const { appUser } = useAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const { settings } = useSystemSettings(); const { settings } = useSystemSettings();
const logoUrl = settings?.appLogoUrl; const logoUrl = settings?.appLogoUrl;
const isAdmin = checkIsAdmin();
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
const fetchProjects = useCallback(async () => { const fetchProjects = useCallback(async () => {
try { try {
@@ -351,17 +352,17 @@ export const ProjectList: React.FC = () => {
return ( return (
<div className="space-y-10 animate-in fade-in duration-700"> <div className="space-y-10 animate-in fade-in duration-700">
{/* Page Header */} {/* Page Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 md:gap-6"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-sm shrink-0"> <div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-sm">
<Activity className="w-5 h-5 sm:w-6 sm:h-6" /> <Activity className="w-6 h-6" />
</div> </div>
<div> <div>
<h1 className="text-2xl sm:text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0"> <h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">
{viewStatus === 'active' ? 'Gestão de Obras' : 'Obras Arquivadas'} {viewStatus === 'active' ? 'Gestão de Obras' : 'Obras Arquivadas'}
</h1> </h1>
<p className="text-xs sm:text-sm text-text-muted font-medium tracking-wide"> <p className="text-sm text-text-muted font-medium tracking-wide">
{viewStatus === 'active' {viewStatus === 'active'
? 'Monitoramento e controle de esquemas industriais' ? 'Monitoramento e controle de esquemas industriais'
: 'Histórico de projetos concluídos e arquivados'} : 'Histórico de projetos concluídos e arquivados'}
@@ -370,22 +371,22 @@ export const ProjectList: React.FC = () => {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setViewStatus(viewStatus === 'active' ? 'archived' : 'active')} onClick={() => setViewStatus(viewStatus === 'active' ? 'archived' : 'active')}
className={`shadow-sm text-xs sm:text-sm ${viewStatus === 'archived' ? 'bg-indigo-500/10 text-indigo-500 border-indigo-500/20' : ''}`} className={`shadow-sm ${viewStatus === 'archived' ? 'bg-indigo-500/10 text-indigo-500 border-indigo-500/20' : ''}`}
> >
{viewStatus === 'active' ? <Box className="w-4 h-4 sm:w-5 sm:h-5 mr-1.5 sm:mr-2" /> : <Activity className="w-4 h-4 sm:w-5 sm:h-5 mr-1.5 sm:mr-2" />} {viewStatus === 'active' ? <Box className="w-5 h-5 mr-2" /> : <Activity className="w-5 h-5 mr-2" />}
{viewStatus === 'active' ? 'Ver Arquivados' : 'Ver Ativos'} {viewStatus === 'active' ? 'Ver Arquivados' : 'Ver Ativos'}
</Button> </Button>
<div className="relative group flex-1 sm:flex-initial min-w-[140px]"> <div className="relative hidden lg:block group">
<Search className="absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
<input <input
type="text" type="text"
placeholder="Buscar obras..." placeholder="Buscar obras..."
className="h-10 sm:h-12 w-full sm:w-48 bg-surface-soft border border-border/40 rounded-xl pl-9 sm:pl-12 pr-3 sm:pr-4 text-xs sm:text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" className="h-12 w-48 bg-surface-soft border border-border/40 rounded-xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
@@ -394,16 +395,16 @@ export const ProjectList: React.FC = () => {
<Button <Button
variant="secondary" variant="secondary"
onClick={handlePrintGeneral} onClick={handlePrintGeneral}
className="shadow-sm text-xs sm:text-sm" className="shadow-sm"
disabled={isPrintingGeneral || projects.length === 0} disabled={isPrintingGeneral || projects.length === 0}
> >
<Printer className="w-4 h-4 sm:w-5 sm:h-5 mr-1.5 sm:mr-2" /> <Printer className="w-5 h-5 mr-2" />
Relatório Geral Relatório Geral
</Button> </Button>
{isAdmin && viewStatus === 'active' && ( {isAdmin && viewStatus === 'active' && (
<Button onClick={() => { setSelectedProject(undefined); setIsModalOpen(true); }} size="md" className="shadow-primary/30 text-xs sm:text-sm"> <Button onClick={() => { setSelectedProject(undefined); setIsModalOpen(true); }} size="md" className="shadow-primary/30">
<Plus className="w-4 h-4 sm:w-5 sm:h-5 mr-1.5 sm:mr-2" /> <Plus className="w-5 h-5 mr-2" />
Novo Projeto Novo Projeto
</Button> </Button>
)} )}
@@ -412,7 +413,7 @@ export const ProjectList: React.FC = () => {
{/* Quick Stats */} {/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[ {[
{ {
label: viewStatus === 'active' ? 'Projetos Ativos' : 'Projetos Arquivados', label: viewStatus === 'active' ? 'Projetos Ativos' : 'Projetos Arquivados',
@@ -452,7 +453,7 @@ export const ProjectList: React.FC = () => {
<div <div
key={i} key={i}
onClick={stat.onClick} onClick={stat.onClick}
className={`p-3.5 sm:p-5 md:p-6 bg-surface border border-border/40 rounded-2xl shadow-sm group transition-all ${stat.interactive ? 'cursor-pointer hover:border-primary/40 hover:shadow-md active:scale-95' : ''}`} className={`p-6 bg-surface border border-border/40 rounded-2xl shadow-sm group transition-all ${stat.interactive ? 'cursor-pointer hover:border-primary/40 hover:shadow-md active:scale-95' : ''}`}
> >
<span className="text-[10px] font-black text-text-muted uppercase tracking-[0.2em]">{stat.label}</span> <span className="text-[10px] font-black text-text-muted uppercase tracking-[0.2em]">{stat.label}</span>
<div className="mt-2 flex items-baseline gap-2"> <div className="mt-2 flex items-baseline gap-2">
+4 -3
View File
@@ -10,7 +10,6 @@ import type { PaintingScheme } from '../types';
import { useAuth } from '../context/useAuth'; import { useAuth } from '../context/useAuth';
export const SchemesList: React.FC = () => { export const SchemesList: React.FC = () => {
const { isAdmin } = useAuth();
const [schemes, setSchemes] = useState<PaintingScheme[]>([]); const [schemes, setSchemes] = useState<PaintingScheme[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editItem, setEditItem] = useState<PaintingScheme | undefined>(undefined); const [editItem, setEditItem] = useState<PaintingScheme | undefined>(undefined);
@@ -18,6 +17,8 @@ export const SchemesList: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false); const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const { appUser } = useAuth();
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
useEffect(() => { useEffect(() => {
fetchSchemes(); fetchSchemes();
@@ -134,7 +135,7 @@ export const SchemesList: React.FC = () => {
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
{isAdmin() && ( {isAdmin && (
<Button onClick={() => { setEditItem(undefined); setIsModalOpen(true); }} size="lg" className="shadow-primary/30 h-14"> <Button onClick={() => { setEditItem(undefined); setIsModalOpen(true); }} size="lg" className="shadow-primary/30 h-14">
<Plus className="w-5 h-5 mr-2" /> <Plus className="w-5 h-5 mr-2" />
Novo Esquema Novo Esquema
@@ -150,7 +151,7 @@ export const SchemesList: React.FC = () => {
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
titleAccessor="name" titleAccessor="name"
subtitleAccessor={(item) => `${item.manufacturer || ''} ${item.type || ''}`} subtitleAccessor={(item) => `${item.manufacturer || ''} ${item.type || ''}`}
actionRender={(item) => isAdmin() ? ( actionRender={(item) => isAdmin ? (
<div className="flex gap-1 justify-end"> <div className="flex gap-1 justify-end">
<button <button
onClick={() => { setCloneItem(item); setIsCloneModalOpen(true); }} onClick={() => { setCloneItem(item); setIsCloneModalOpen(true); }}
+17 -18
View File
@@ -10,6 +10,7 @@ import { useAuth } from '../context/useAuth';
import { useSystemSettings } from '../context/SystemSettingsContext'; import { useSystemSettings } from '../context/SystemSettingsContext';
export const StockDashboard: React.FC = () => { export const StockDashboard: React.FC = () => {
// ... rest of component
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { settings } = useSystemSettings(); const { settings } = useSystemSettings();
@@ -79,12 +80,11 @@ export const StockDashboard: React.FC = () => {
await Promise.all( await Promise.all(
items.map(async (item) => { items.map(async (item) => {
try { try {
const itemId = item.id || (item as any)._id; const movements = await stockService.getMovements(item._id!);
if (!itemId) return; movementsMap.set(item._id!, movements);
const movements = await stockService.getMovements(itemId);
movementsMap.set(itemId, movements);
} catch (error) { } catch (error) {
console.error(`Error fetching movements for ${item.id}:`, error); console.error(`Error fetching movements for ${item._id}:`, error);
movementsMap.set(item._id!, []);
} }
}) })
); );
@@ -107,19 +107,19 @@ export const StockDashboard: React.FC = () => {
const filteredItems = items.filter(item => { const filteredItems = items.filter(item => {
const searchLower = searchTerm.toLowerCase(); const searchLower = searchTerm.toLowerCase();
// Handle type checking carefully. If type is missing, assume PAINT. // Handle type checking carefully. If type is missing, assume PAINT.
const type = (typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).type : '') || 'PAINT'; const type = (typeof item.dataSheetId === 'object' ? item.dataSheetId.type : '') || 'PAINT';
const isThinner = type === 'THINNER' || type === 'DILUENTE'; const isThinner = type === 'THINNER' || type === 'DILUENTE';
// Tab Filter // Tab Filter
if (activeTab === 'THINNER' && !isThinner) return false; if (activeTab === 'THINNER' && !isThinner) return false;
if (activeTab === 'PAINT' && isThinner) return false; if (activeTab === 'PAINT' && isThinner) return false;
const productName = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).name : ''; const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : '';
const manufacturer = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).manufacturer : ''; const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
return ( return (
(item.rrNumber || '').toLowerCase().includes(searchLower) || item.rrNumber.toLowerCase().includes(searchLower) ||
(item.batchNumber || '').toLowerCase().includes(searchLower) || item.batchNumber.toLowerCase().includes(searchLower) ||
productName.toLowerCase().includes(searchLower) || productName.toLowerCase().includes(searchLower) ||
manufacturer.toLowerCase().includes(searchLower) manufacturer.toLowerCase().includes(searchLower)
); );
@@ -129,10 +129,9 @@ export const StockDashboard: React.FC = () => {
const groups = new Map<string, { items: StockItem[], totalQty: number, minStock: number, unit: string, productName: string, color: string, manufacturer: string }>(); const groups = new Map<string, { items: StockItem[], totalQty: number, minStock: number, unit: string, productName: string, color: string, manufacturer: string }>();
filteredItems.forEach(item => { filteredItems.forEach(item => {
const productName = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).name : 'Unknown'; const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Unknown';
const manufacturer = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).manufacturer : ''; const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
const dsId = (item.dataSheetId as any).id || (item.dataSheetId as any)._id || item.dataSheetId; const key = `${item.dataSheetId._id || item.dataSheetId}-${item.color}`;
const key = `${dsId}-${item.color}`;
if (!groups.has(key)) { if (!groups.has(key)) {
groups.set(key, { groups.set(key, {
@@ -297,7 +296,7 @@ export const StockDashboard: React.FC = () => {
<td className="px-6 py-4 font-bold text-lg"> <td className="px-6 py-4 font-bold text-lg">
<span className={isLowStock ? 'text-red-500 animate-blink flex items-center gap-2' : 'text-green-500'}> <span className={isLowStock ? 'text-red-500 animate-blink flex items-center gap-2' : 'text-green-500'}>
{isLowStock && <AlertCircle size={16} />} {isLowStock && <AlertCircle size={16} />}
{(group.totalQty || 0).toFixed(1)} {group.unit} {group.totalQty.toFixed(1)} {group.unit}
</span> </span>
{group.minStock > 0 && ( {group.minStock > 0 && (
<span className="block text-[10px] text-text-muted font-normal"> <span className="block text-[10px] text-text-muted font-normal">
@@ -315,11 +314,11 @@ export const StockDashboard: React.FC = () => {
{/* Expanded Item Rows */} {/* Expanded Item Rows */}
{isExpanded && group.items.map(item => { {isExpanded && group.items.map(item => {
const itemId = item.id || (item as any)._id;
const isExpired = item.expirationDate && new Date(item.expirationDate) < new Date(); const isExpired = item.expirationDate && new Date(item.expirationDate) < new Date();
// Check individual item min stock for legacy reasons? No, rely on group.
return ( return (
<tr key={itemId} className="bg-surface-soft/50 hover:bg-surface-hover/80 transition-colors border-l-4 border-l-primary/20"> <tr key={item._id} className="bg-surface-soft/50 hover:bg-surface-hover/80 transition-colors border-l-4 border-l-primary/20">
<td className="px-6 py-3"></td> {/* Indentation */} <td className="px-6 py-3"></td> {/* Indentation */}
<td className="px-6 py-3 font-mono text-xs text-text-muted"> <td className="px-6 py-3 font-mono text-xs text-text-muted">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -373,7 +372,7 @@ export const StockDashboard: React.FC = () => {
<Edit size={16} /> <Edit size={16} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDelete(itemId!); }} onClick={(e) => { e.stopPropagation(); handleDelete(item._id!); }}
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg" className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg"
title="Excluir" title="Excluir"
> >
+87 -92
View File
@@ -108,11 +108,11 @@ export const YieldStudyDashboard: React.FC = () => {
]); ]);
// Normalizar estudos para garantir que as categorias tenham o campo 'id' // Normalizar estudos para garantir que as categorias tenham o campo 'id'
const normalizedStudies: YieldStudy[] = (studiesRes.data || []).map((s: YieldStudy) => ({ const normalizedStudies = (studiesRes.data || []).map((s: YieldStudy) => ({
...s, ...s,
categories: (s.categories || []).map((cat): PieceCategory => ({ categories: (s.categories || []).map(cat => ({
...cat, ...cat,
id: (cat.id || (cat as { _id?: string })._id) as string // Garante ID único id: cat.id || (cat as any)._id // Garante ID único
})) }))
})); }));
@@ -156,11 +156,11 @@ export const YieldStudyDashboard: React.FC = () => {
try { try {
const res = await yieldStudyService.createStudy(newStudy); const res = await yieldStudyService.createStudy(newStudy);
const normalized: YieldStudy = { const normalized = {
...res.data, ...res.data,
categories: res.data.categories.map((cat: PieceCategory & { _id?: string }): PieceCategory => ({ categories: res.data.categories.map((cat: any) => ({
...cat, ...cat,
id: (cat.id || cat._id) as string id: cat.id || cat._id
})) }))
}; };
setStudies([normalized, ...studies]); setStudies([normalized, ...studies]);
@@ -226,7 +226,7 @@ export const YieldStudyDashboard: React.FC = () => {
// 1. Aplica a atualização na categoria específica // 1. Aplica a atualização na categoria específica
const newCategories = selectedStudy.categories.map(c => { const newCategories = selectedStudy.categories.map(c => {
const currentId = (c.id || (c as { _id?: string })._id) as string; const currentId = c.id || (c as any)._id;
return currentId === id ? { ...c, ...updates } : c; return currentId === id ? { ...c, ...updates } : c;
}); });
@@ -287,7 +287,7 @@ export const YieldStudyDashboard: React.FC = () => {
const dilutionFactor = 100 - study.dilutionPercent; const dilutionFactor = 100 - study.dilutionPercent;
const svFactor = sv * dilutionFactor; const svFactor = sv * dilutionFactor;
const calculatedEpu = svFactor > 0 const calculatedEpu = svFactor > 0
? Number((((study.targetDft || 0) * 10000) / svFactor).toFixed(1)) ? Number((study.targetDft * 10000 / svFactor).toFixed(1))
: 0; : 0;
let totalWeight = 0; let totalWeight = 0;
@@ -298,12 +298,11 @@ export const YieldStudyDashboard: React.FC = () => {
const weightInTons = Number(cat.weight) || 0; const weightInTons = Number(cat.weight) || 0;
const yieldPerTon = Number(cat.historicalYield) || 0; const yieldPerTon = Number(cat.historicalYield) || 0;
const rawVal = Number(cat.efficiency); const rawVal = Number(cat.efficiency);
const effVal = !isNaN(rawVal) && rawVal > 0 ? rawVal : 80; const val = !isNaN(rawVal) ? rawVal : 20;
// Eficiência de Aplicação (%): 100% = sem perda (lossFactor = 1.0), 80% = 20% perda (lossFactor = 1.25), etc. const isLegacyEfficiency = val > 60;
const effPercent = Math.min(Math.max(effVal, 1), 100); const efficiencyDecimal = isLegacyEfficiency ? val / 100 : (100 - val) / 100;
const efficiencyDecimal = effPercent / 100; const lossFactor = efficiencyDecimal > 0 ? 1 / efficiencyDecimal : 1;
const lossFactor = 1 / efficiencyDecimal;
// Cálculo por PESO (L/t) - Não usa mais o fator de eficiência/perda // Cálculo por PESO (L/t) - Não usa mais o fator de eficiência/perda
const litrosPeso = weightInTons * yieldPerTon; const litrosPeso = weightInTons * yieldPerTon;
@@ -321,8 +320,8 @@ export const YieldStudyDashboard: React.FC = () => {
return { return {
...cat, ...cat,
litrosPeso: Number((litrosPeso || 0).toFixed(2)), litrosPeso: Number(litrosPeso.toFixed(2)),
litrosArea: litrosArea > 0 ? Number((litrosArea || 0).toFixed(2)) : undefined litrosArea: litrosArea > 0 ? Number(litrosArea.toFixed(2)) : undefined
}; };
}); });
@@ -333,10 +332,10 @@ export const YieldStudyDashboard: React.FC = () => {
...study, ...study,
categories: updatedCategories, categories: updatedCategories,
totalWeight, totalWeight,
estimatedPaintVolume: Number((totalVolumeByWeight || 0).toFixed(2)), estimatedPaintVolume: Number(totalVolumeByWeight.toFixed(2)),
estimatedReducerVolume: Number((reducerVolByWeight || 0).toFixed(2)), estimatedReducerVolume: Number(reducerVolByWeight.toFixed(2)),
estimatedPaintVolumeByArea: Number((totalVolumeByArea || 0).toFixed(2)), estimatedPaintVolumeByArea: Number(totalVolumeByArea.toFixed(2)),
estimatedReducerVolumeByArea: Number((reducerVolByArea || 0).toFixed(2)), estimatedReducerVolumeByArea: Number(reducerVolByArea.toFixed(2)),
calculatedEpu: calculatedEpu calculatedEpu: calculatedEpu
} as YieldStudy & { calculatedEpu: number }); } as YieldStudy & { calculatedEpu: number });
}; };
@@ -354,11 +353,11 @@ export const YieldStudyDashboard: React.FC = () => {
// Data for deviation projection // Data for deviation projection
// Data for deviation projection - Lógica Direta (Mais DFT = Mais Tinta) // Data for deviation projection - Lógica Direta (Mais DFT = Mais Tinta)
const projectionData = selectedStudy ? [ const projectionData = selectedStudy ? [
{ dft: ((selectedStudy.targetDft || 0) * 0.8).toFixed(0), vol: Number(((selectedStudy.estimatedPaintVolume || 0) * 0.8).toFixed(1)), label: `-20%` }, { dft: (selectedStudy.targetDft * 0.8).toFixed(0), vol: Number((selectedStudy.estimatedPaintVolume * 0.8).toFixed(1)), label: `-20%` },
{ dft: ((selectedStudy.targetDft || 0) * 0.9).toFixed(0), vol: Number(((selectedStudy.estimatedPaintVolume || 0) * 0.9).toFixed(1)), label: `-10%` }, { dft: (selectedStudy.targetDft * 0.9).toFixed(0), vol: Number((selectedStudy.estimatedPaintVolume * 0.9).toFixed(1)), label: `-10%` },
{ dft: (selectedStudy.targetDft || 0).toFixed(0), vol: (selectedStudy.estimatedPaintVolume || 0), label: 'ALVO' }, { dft: selectedStudy.targetDft.toFixed(0), vol: selectedStudy.estimatedPaintVolume, label: 'ALVO' },
{ dft: ((selectedStudy.targetDft || 0) * 1.1).toFixed(0), vol: Number(((selectedStudy.estimatedPaintVolume || 0) * 1.1).toFixed(1)), label: '+10%' }, { dft: (selectedStudy.targetDft * 1.1).toFixed(0), vol: Number((selectedStudy.estimatedPaintVolume * 1.1).toFixed(1)), label: '+10%' },
{ dft: ((selectedStudy.targetDft || 0) * 1.3).toFixed(0), vol: Number(((selectedStudy.estimatedPaintVolume || 0) * 1.3).toFixed(1)), label: '+30%' }, { dft: (selectedStudy.targetDft * 1.3).toFixed(0), vol: Number((selectedStudy.estimatedPaintVolume * 1.3).toFixed(1)), label: '+30%' },
] : []; ] : [];
if (loading) return <div className="p-8 text-center text-text-muted">Carregando estudos...</div>; if (loading) return <div className="p-8 text-center text-text-muted">Carregando estudos...</div>;
@@ -428,11 +427,11 @@ export const YieldStudyDashboard: React.FC = () => {
<div className="absolute -inset-1 bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 rounded-[32px] blur-xl opacity-0 group-hover:opacity-100 transition-all duration-700"></div> <div className="absolute -inset-1 bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 rounded-[32px] blur-xl opacity-0 group-hover:opacity-100 transition-all duration-700"></div>
<Card <Card
onClick={() => { onClick={() => {
const normalized: YieldStudy = { const normalized = {
...study, ...study,
categories: study.categories.map((cat): PieceCategory => ({ categories: study.categories.map(cat => ({
...cat, ...cat,
id: (cat.id || (cat as { _id?: string })._id) as string id: cat.id || (cat as any)._id
})) }))
}; };
setSelectedStudy(normalized); setSelectedStudy(normalized);
@@ -467,11 +466,11 @@ export const YieldStudyDashboard: React.FC = () => {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[9px] font-black text-text-muted uppercase tracking-[0.2em] mb-1">Carga Total</span> <span className="text-[9px] font-black text-text-muted uppercase tracking-[0.2em] mb-1">Carga Total</span>
<span className="text-sm font-black text-text-main">{(study.totalWeight || 0).toFixed(1)} t</span> <span className="text-sm font-black text-text-main">{study.totalWeight.toFixed(1)} t</span>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[9px] font-black text-text-muted uppercase tracking-[0.2em] mb-1">Target DFT</span> <span className="text-[9px] font-black text-text-muted uppercase tracking-[0.2em] mb-1">Target DFT</span>
<span className="text-sm font-black text-text-main">{(study.targetDft || 0)} <span className="text-[10px] text-text-muted">μm</span></span> <span className="text-sm font-black text-text-main">{study.targetDft} <span className="text-[10px] text-text-muted">μm</span></span>
</div> </div>
</div> </div>
</div> </div>
@@ -559,9 +558,9 @@ export const YieldStudyDashboard: React.FC = () => {
const sheet = findSheet(selectedStudy.dataSheetId); const sheet = findSheet(selectedStudy.dataSheetId);
let sv = sheet?.solidsVolume || 60; let sv = sheet?.solidsVolume || 60;
if (sv <= 1) sv *= 100; if (sv <= 1) sv *= 100;
const dilFactor = 100 - (selectedStudy.dilutionPercent || 0); const dilFactor = 100 - selectedStudy.dilutionPercent;
const svFactor = sv * dilFactor; const svFactor = sv * dilFactor;
return svFactor > 0 ? ((selectedStudy.targetDft || 0) * 10000 / svFactor).toFixed(1) : '0'; return svFactor > 0 ? (selectedStudy.targetDft * 10000 / svFactor).toFixed(1) : '0';
})() })()
} <span className="text-xs">µm</span> } <span className="text-xs">µm</span>
</div> </div>
@@ -573,19 +572,19 @@ export const YieldStudyDashboard: React.FC = () => {
const hasRealSV = sheet?.solidsVolume && sheet.solidsVolume > 0; const hasRealSV = sheet?.solidsVolume && sheet.solidsVolume > 0;
let sv = sheet?.solidsVolume || 60; let sv = sheet?.solidsVolume || 60;
if (sv <= 1) sv *= 100; if (sv <= 1) sv *= 100;
return ( return (
<> <>
<span className={`text-[9px] font-black uppercase tracking-wider ${hasRealSV ? 'text-success' : 'text-amber-500'}`}> <span className={`text-[9px] font-black uppercase tracking-wider ${hasRealSV ? 'text-success' : 'text-amber-500'}`}>
SV da Tinta {hasRealSV ? '✓' : '⚠️'} SV da Tinta {hasRealSV ? '✓' : '⚠️'}
</span> </span>
<div className={`text-2xl font-black leading-none ${hasRealSV ? 'text-text-main' : 'text-amber-500'}`}> <div className={`text-2xl font-black leading-none ${hasRealSV ? 'text-text-main' : 'text-amber-500'}`}>
{(sv || 0).toFixed(0)} <span className="text-xs">%</span> {sv.toFixed(0)} <span className="text-xs">%</span>
</div> </div>
<span className="text-[8px] text-text-muted"> <span className="text-[8px] text-text-muted">
{hasRealSV ? 'Sólidos por Volume' : 'Valor padrão (edite a ficha)'} {hasRealSV ? 'Sólidos por Volume' : 'Valor padrão (edite a ficha)'}
</span> </span>
</> </>
); );
})()} })()}
</div> </div>
</div> </div>
@@ -618,13 +617,13 @@ export const YieldStudyDashboard: React.FC = () => {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-[10px] font-bold text-text-muted uppercase">Taxa Média</span> <span className="text-[10px] font-bold text-text-muted uppercase">Taxa Média</span>
<span className="text-sm font-black text-text-main"> <span className="text-sm font-black text-text-main">
{selectedStudy.totalWeight > 0 ? (((selectedStudy.estimatedPaintVolume || 0) / selectedStudy.totalWeight).toFixed(2)) : '0.00'} <span className="text-[10px] text-text-muted">L/t</span> {selectedStudy.totalWeight > 0 ? ((selectedStudy.estimatedPaintVolume / selectedStudy.totalWeight).toFixed(2)) : '0.00'} <span className="text-[10px] text-text-muted">L/t</span>
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-[10px] font-bold text-text-muted uppercase">Peso Total</span> <span className="text-[10px] font-bold text-text-muted uppercase">Peso Total</span>
<span className="text-sm font-black text-primary"> <span className="text-sm font-black text-primary">
{(selectedStudy.totalWeight || 0).toFixed(2)} TON {selectedStudy.totalWeight.toFixed(2)} TON
</span> </span>
</div> </div>
</div> </div>
@@ -707,10 +706,10 @@ export const YieldStudyDashboard: React.FC = () => {
onChange={e => { onChange={e => {
const selectedName = e.target.value; const selectedName = e.target.value;
const geoType = geometryTypes.find(g => g.name === selectedName); const geoType = geometryTypes.find(g => g.name === selectedName);
// Update name and auto-set efficiency if found (100% - loss%) // Update name and auto-set efficiency (loss) if found
updateCategory(cat.id, { updateCategory(cat.id, {
name: selectedName, name: selectedName,
...(geoType && { efficiency: Math.max(10, 100 - (geoType.efficiencyLoss || 20)) }) ...(geoType && { efficiency: geoType.efficiencyLoss || 20 })
}); });
}} }}
> >
@@ -789,13 +788,11 @@ export const YieldStudyDashboard: React.FC = () => {
<Ruler size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" /> <Ruler size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input <input
type="number" type="number"
min="1"
max="100"
className="w-full h-11 bg-surface border border-border/40 rounded-xl pl-9 pr-4 text-sm font-black outline-none focus:border-primary transition-all text-blue-600" className="w-full h-11 bg-surface border border-border/40 rounded-xl pl-9 pr-4 text-sm font-black outline-none focus:border-primary transition-all text-blue-600"
value={cat.efficiency} value={cat.efficiency}
onChange={e => updateCategory(cat.id, { efficiency: Number(e.target.value) })} onChange={e => updateCategory(cat.id, { efficiency: Number(e.target.value) })}
title="Eficiência de Aplicação (%) - 100% representa zero perda" title="Percentual de Eficiência/Perda (Aplica-se apenas ao cálculo por Área)"
placeholder="80" placeholder="20"
/> />
</div> </div>
</div> </div>
@@ -926,25 +923,25 @@ export const YieldStudyDashboard: React.FC = () => {
</div> </div>
{/* Cards de Resumo (Estilo do Relatório de Obras) */} {/* Cards de Resumo (Estilo do Relatório de Obras) */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4 mb-6 sm:mb-8"> <div className="grid grid-cols-4 gap-4 mb-8">
<div className="border border-gray-300 rounded-xl p-3 sm:p-4 space-y-1"> <div className="border border-gray-300 rounded-xl p-4 space-y-1">
<span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Peso Total (Ton)</span> <span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Peso Total (Ton)</span>
<div className="text-lg sm:text-xl font-black">{(selectedStudy.totalWeight || 0).toFixed(2)}</div> <div className="text-xl font-black">{selectedStudy.totalWeight.toFixed(2)}</div>
<p className="text-[7px] text-gray-400 font-bold uppercase">Soma das categorias</p> <p className="text-[7px] text-gray-400 font-bold uppercase">Soma das categorias</p>
</div> </div>
<div className="border border-gray-300 rounded-xl p-3 sm:p-4 space-y-1"> <div className="border border-gray-300 rounded-xl p-4 space-y-1">
<span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Tinta Total (Peso)</span> <span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Tinta Total (Peso)</span>
<div className="text-lg sm:text-xl font-black text-amber-600">{Math.round(selectedStudy.estimatedPaintVolume)} <span className="text-[10px]">L</span></div> <div className="text-xl font-black text-amber-600">{Math.round(selectedStudy.estimatedPaintVolume)} <span className="text-[10px]">L</span></div>
<p className="text-[7px] text-gray-400 font-bold uppercase">Baseado em L/t</p> <p className="text-[7px] text-gray-400 font-bold uppercase">Baseado em L/t</p>
</div> </div>
<div className="border border-gray-300 rounded-xl p-3 sm:p-4 space-y-1"> <div className="border border-gray-300 rounded-xl p-4 space-y-1">
<span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Tinta Total (Área)</span> <span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Tinta Total (Área)</span>
<div className="text-lg sm:text-xl font-black text-blue-600">{Math.round(selectedStudy.estimatedPaintVolumeByArea || 0)} <span className="text-[10px]">L</span></div> <div className="text-xl font-black text-blue-600">{Math.round(selectedStudy.estimatedPaintVolumeByArea || 0)} <span className="text-[10px]">L</span></div>
<p className="text-[7px] text-gray-400 font-bold uppercase">Baseado em m²</p> <p className="text-[7px] text-gray-400 font-bold uppercase">Baseado em m²</p>
</div> </div>
<div className="border border-gray-300 rounded-xl p-3 sm:p-4 space-y-1 border-black bg-gray-50"> <div className="border border-gray-300 rounded-xl p-4 space-y-1 border-black bg-gray-50">
<span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Taxa Média</span> <span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Taxa Média</span>
<div className="text-lg sm:text-xl font-black">{selectedStudy.totalWeight > 0 ? ((selectedStudy.estimatedPaintVolume || 0) / selectedStudy.totalWeight).toFixed(2) : '0.00'} <span className="text-[10px]">L/t</span></div> <div className="text-xl font-black">{selectedStudy.totalWeight > 0 ? (selectedStudy.estimatedPaintVolume / selectedStudy.totalWeight).toFixed(2) : '0.00'} <span className="text-[10px]">L/t</span></div>
<p className="text-[7px] text-gray-400 font-bold uppercase">Rendimento Global</p> <p className="text-[7px] text-gray-400 font-bold uppercase">Rendimento Global</p>
</div> </div>
</div> </div>
@@ -955,41 +952,39 @@ export const YieldStudyDashboard: React.FC = () => {
<h2 className="text-xs font-black uppercase tracking-[0.2em]">Detalhamento por Categoria</h2> <h2 className="text-xs font-black uppercase tracking-[0.2em]">Detalhamento por Categoria</h2>
<p className="text-[8px] text-gray-500 font-bold uppercase">Visão analítica por geometria, peso e área</p> <p className="text-[8px] text-gray-500 font-bold uppercase">Visão analítica por geometria, peso e área</p>
</div> </div>
<div className="overflow-x-auto"> <table className="w-full text-left border-collapse">
<table className="w-full text-left border-collapse min-w-[500px]"> <thead>
<thead> <tr className="border-b-2 border-black">
<tr className="border-b-2 border-black"> <th className="py-2 text-[9px] font-black uppercase tracking-widest">Categoria / Tipo</th>
<th className="py-2 text-[9px] font-black uppercase tracking-widest">Categoria / Tipo</th> <th className="py-2 text-[9px] font-black uppercase tracking-widest text-center text-amber-600">Peso (T)</th>
<th className="py-2 text-[9px] font-black uppercase tracking-widest text-center text-amber-600">Peso (T)</th> <th className="py-2 text-[9px] font-black uppercase tracking-widest text-center text-blue-600">Área (m²)</th>
<th className="py-2 text-[9px] font-black uppercase tracking-widest text-center text-blue-600">Área (m²)</th> <th className="py-2 text-[9px] font-black uppercase tracking-widest text-center text-amber-600">Taxa (L/t)</th>
<th className="py-2 text-[9px] font-black uppercase tracking-widest text-center text-amber-600">Taxa (L/t)</th> <th className="py-2 text-[9px] font-black uppercase tracking-widest text-center text-blue-600">Efic. (%)</th>
<th className="py-2 text-[9px] font-black uppercase tracking-widest text-center text-blue-600">Efic. (%)</th> <th className="py-2 text-[9px] font-black uppercase tracking-widest text-right">Consumo (L)</th>
<th className="py-2 text-[9px] font-black uppercase tracking-widest text-right">Consumo (L)</th> </tr>
</thead>
<tbody className="divide-y divide-gray-100">
{selectedStudy.categories.map((cat, idx) => (
<tr key={cat.id || idx} className="border-b border-gray-100 italic">
<td className="py-3 pr-4">
<div className="text-[11px] font-black text-gray-800">{cat.name}</div>
</td>
<td className="py-3 text-center text-[10px] font-bold text-amber-700">{cat.weight.toFixed(2)}</td>
<td className="py-3 text-center text-[10px] font-bold text-blue-700">{cat.area ? Math.round(cat.area) : '--'}</td>
<td className="py-3 text-center text-[10px] font-bold text-amber-700">{cat.historicalYield}</td>
<td className="py-3 text-center text-[10px] font-bold text-blue-700">{cat.efficiency}%</td>
<td className="py-3 text-right">
<div className="text-[10px] font-black text-amber-700">{cat.litrosPeso} L <span className="text-[7px] uppercase font-bold text-gray-400 opacity-50">(p)</span></div>
{cat.litrosArea && <div className="text-[10px] font-black text-blue-700">{cat.litrosArea} L <span className="text-[7px] uppercase font-bold text-gray-400 opacity-50">(a)</span></div>}
</td>
</tr> </tr>
</thead> ))}
<tbody className="divide-y divide-gray-100"> </tbody>
{selectedStudy.categories.map((cat, idx) => ( </table>
<tr key={cat.id || idx} className="border-b border-gray-100 italic">
<td className="py-3 pr-4">
<div className="text-[11px] font-black text-gray-800">{cat.name}</div>
</td>
<td className="py-3 text-center text-[10px] font-bold text-amber-700">{(cat.weight || 0).toFixed(2)}</td>
<td className="py-3 text-center text-[10px] font-bold text-blue-700">{cat.area ? Math.round(cat.area) : '--'}</td>
<td className="py-3 text-center text-[10px] font-bold text-amber-700">{cat.historicalYield}</td>
<td className="py-3 text-center text-[10px] font-bold text-blue-700">{cat.efficiency}%</td>
<td className="py-3 text-right">
<div className="text-[10px] font-black text-amber-700">{cat.litrosPeso} L <span className="text-[7px] uppercase font-bold text-gray-400 opacity-50">(p)</span></div>
{cat.litrosArea && <div className="text-[10px] font-black text-blue-700">{cat.litrosArea} L <span className="text-[7px] uppercase font-bold text-gray-400 opacity-50">(a)</span></div>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
{/* Parâmetros e Projeção */} {/* Parâmetros e Projeção */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-8 mb-6 sm:mb-8"> <div className="grid grid-cols-2 gap-8 mb-8">
<div> <div>
<h3 className="text-[10px] font-black uppercase tracking-widest mb-4 border-b border-gray-200 pb-2">Especificações Técnicas</h3> <h3 className="text-[10px] font-black uppercase tracking-widest mb-4 border-b border-gray-200 pb-2">Especificações Técnicas</h3>
<div className="space-y-3"> <div className="space-y-3">
+27 -28
View File
@@ -1,8 +1,9 @@
// API service configuration v2.0 - Logto Auth // API service configuration v1.4 - with auth and error interceptors
import axios from 'axios'; import axios from 'axios';
import { triggerGuestWarning } from '../utils/toastHandler'; import { triggerGuestWarning } from '../utils/toastHandler';
export const getBaseUrl = () => { export const getBaseUrl = () => {
// Priority: Env var -> Relative path (handled by Vite proxy in dev, or Nginx/Vercel in prod)
if (import.meta.env.VITE_API_URL) { if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL; return import.meta.env.VITE_API_URL;
} }
@@ -16,48 +17,44 @@ const api = axios.create({
}, },
}); });
let currentToken: string | null = null;
let currentOrgId: string | null = null; let currentOrgId: string | null = null;
let currentOrgName: string | null = null; let currentOrgName: string | null = null;
// Function to set the JWT token
export const setApiToken = (token: string | null) => {
currentToken = token;
};
// Function to set the organization ID and Name (called from Layout/Context)
export const setApiOrgData = (orgId: string | null, orgName: string | null = null) => { export const setApiOrgData = (orgId: string | null, orgName: string | null = null) => {
currentOrgId = orgId; currentOrgId = orgId;
currentOrgName = orgName; currentOrgName = orgName;
}; };
export const setApiOrganizationId = setApiOrgData; // Legacy support
export const setApiOrgId = (orgId: string | null) => {
setApiOrgData(orgId, null);
};
// Alias for consistency
export const setApiOrganizationId = setApiOrgId;
// Request interceptor to add clerk user ID and Org ID headers
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
let token = localStorage.getItem('gpi_token'); console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`, {
if (!token) { orgId: currentOrgId
const userStr = localStorage.getItem('gpi_user'); });
if (userStr) { if (currentToken) {
token = 'gpi-admin-token'; config.headers['Authorization'] = `Bearer ${currentToken}`;
localStorage.setItem('gpi_token', 'gpi-admin-token');
} else {
token = 'gpi-admin-token';
}
}
if (token) {
if (config.headers && typeof (config.headers as any).set === 'function') {
(config.headers as any).set('Authorization', `Bearer ${token}`);
} else {
config.headers['Authorization'] = `Bearer ${token}`;
}
} }
if (currentOrgId) { if (currentOrgId) {
if (config.headers && typeof (config.headers as any).set === 'function') { config.headers['x-organization-id'] = currentOrgId;
(config.headers as any).set('x-organization-id', currentOrgId);
} else {
config.headers['x-organization-id'] = currentOrgId;
}
} }
if (currentOrgName) { if (currentOrgName) {
if (config.headers && typeof (config.headers as any).set === 'function') { // Encode to handle special characters
(config.headers as any).set('x-organization-name', encodeURIComponent(currentOrgName)); config.headers['x-organization-name'] = encodeURIComponent(currentOrgName);
} else {
config.headers['x-organization-name'] = encodeURIComponent(currentOrgName);
}
} }
return config; return config;
}, },
@@ -66,10 +63,12 @@ api.interceptors.request.use(
} }
); );
// Response interceptor to handle 403 errors (guest access denied)
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 403) { if (error.response?.status === 403) {
// Check if it's a guest permission error
const errorMessage = error.response?.data?.error || ''; const errorMessage = error.response?.data?.error || '';
if (errorMessage.includes('Convidados') || errorMessage.includes('guest') || errorMessage.includes('permissão')) { if (errorMessage.includes('Convidados') || errorMessage.includes('guest') || errorMessage.includes('permissão')) {
triggerGuestWarning(); triggerGuestWarning();
+3 -4
View File
@@ -15,7 +15,7 @@ export const systemSettingsService = {
}, },
updateSettings: async (settings: Partial<SystemSettings>): Promise<SystemSettings> => { updateSettings: async (settings: Partial<SystemSettings>): Promise<SystemSettings> => {
// Axios interceptors handle organization-id headers // Axios interceptors in api.ts automatically handle x-auth-user-id and x-organization-id headers
const response = await api.put('/system-settings', settings); const response = await api.put('/system-settings', settings);
return response.data; return response.data;
}, },
@@ -51,7 +51,7 @@ export const systemSettingsService = {
export interface GlobalUser { export interface GlobalUser {
_id: string; _id: string;
id: string; externalId: string;
name: string; name: string;
email: string; email: string;
role: string; role: string;
@@ -61,9 +61,8 @@ export interface GlobalUser {
export interface GlobalOrganization { export interface GlobalOrganization {
_id: string; // organizationId _id: string; // organizationId
id?: string;
memberCount: number; memberCount: number;
lastActive?: string; lastActive: string;
isBanned: boolean; isBanned: boolean;
name?: string; // Added name?: string; // Added
members: { members: {
+1 -1
View File
@@ -190,7 +190,7 @@ export type UserRole = 'guest' | 'user' | 'admin';
export interface AppUser { export interface AppUser {
id: string; id: string;
_id?: string; _id?: string;
logtoId?: string; externalId: string;
email: string; email: string;
name: string; name: string;
role: UserRole; role: UserRole;
+52 -84
View File
@@ -1,12 +1,5 @@
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import path from 'path';
import fs from 'fs';
import { extractUser } from './middleware/authMiddleware.js';
import projectRoutes from './routes/projectRoutes.js'; import projectRoutes from './routes/projectRoutes.js';
import partRoutes from './routes/partRoutes.js'; import partRoutes from './routes/partRoutes.js';
import paintingSchemeRoutes from './routes/paintingSchemeRoutes.js'; import paintingSchemeRoutes from './routes/paintingSchemeRoutes.js';
@@ -18,106 +11,81 @@ import yieldStudyRoutes from './routes/yieldStudyRoutes.js';
import userRoutes from './routes/userRoutes.js'; import userRoutes from './routes/userRoutes.js';
import systemSettingsRoutes from './routes/systemSettingsRoutes.js'; import systemSettingsRoutes from './routes/systemSettingsRoutes.js';
import geometryTypeRoutes from './routes/geometryTypeRoutes.js'; import geometryTypeRoutes from './routes/geometryTypeRoutes.js';
import authRoutes from './routes/authRoutes.js';
import stockRoutes from './routes/stockRoutes.js'; import stockRoutes from './routes/stockRoutes.js';
import notificationRoutes from './routes/notificationRoutes.js'; import notificationRoutes from './routes/notificationRoutes.js';
import instrumentRoutes from './routes/instrumentRoutes.js'; import instrumentRoutes from './routes/instrumentRoutes.js';
import messageRoutes from './routes/messageRoutes.js'; import messageRoutes from './routes/messageRoutes.js';
import backupRoutes from './routes/backupRoutes.js'; import backupRoutes from './routes/backupRoutes.js';
import path from 'path';
const app = express(); const app = express();
// Security Headers app.use(cors({
app.use(helmet({ origin: '*', // Be more specific in production
contentSecurityPolicy: false, // Pode interferir com o React em dev se não configurado direito methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
crossOriginEmbedderPolicy: false allowedHeaders: ['Content-Type', 'Authorization', 'x-organization-id']
})); }));
app.use(express.json());
import { extractUser } from './middleware/roleMiddleware.js';
// Rate Limiter // LOG DE DEPURAÇÃO PARA CONEXÃO
const apiLimiter = rateLimit({ app.use((req, res, next) => {
windowMs: 15 * 60 * 1000, // 15 minutes console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
max: 1000, // limit each IP to 1000 requests per windowMs next();
message: 'Too many requests from this IP, please try again later.'
}); });
// Apply rate limiter only to API routes import { authMiddleware } from './middleware/auth.js';
app.use('/api', apiLimiter); app.use(authMiddleware);
app.use(extractUser);
// CORS
const allowedOrigins = [
'https://gpi.reifonas.cloud',
'http://localhost:5173',
'http://localhost:3000'
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-organization-id', 'x-organization-name']
}));
app.use(express.json({ limit: '5mb' }));
app.use(express.urlencoded({ limit: '5mb', extended: true }));
// Static Uploads // Static Uploads
import fs from 'fs';
const uploadsPath = path.join(process.cwd(), 'uploads'); const uploadsPath = path.join(process.cwd(), 'uploads');
// Ensure uploads directory exists
if (!fs.existsSync(uploadsPath)) { if (!fs.existsSync(uploadsPath)) {
fs.mkdirSync(uploadsPath, { recursive: true }); fs.mkdirSync(uploadsPath, { recursive: true });
} }
app.use('/uploads', express.static(uploadsPath)); app.use('/uploads', express.static(uploadsPath));
// Serve frontend static files // Routes
const distPath = path.join(process.cwd(), 'dist'); app.use('/api/auth', authRoutes);
app.use(express.static(distPath)); app.use('/api/users', userRoutes);
app.use('/api/projects', projectRoutes);
// API Routes (all API routes pass through extractUser here, avoid duplication in route files) app.use('/api/parts', partRoutes);
const apiRouter = express.Router(); app.use('/api/painting-schemes', paintingSchemeRoutes);
apiRouter.use(extractUser); app.use('/api/application-records', applicationRecordRoutes);
app.use('/api/inspections', inspectionRoutes);
apiRouter.use('/users', userRoutes); app.use('/api', analysisRoutes);
apiRouter.use('/projects', projectRoutes); app.use('/api/datasheets', dataSheetRoutes);
apiRouter.use('/parts', partRoutes); app.use('/api/datasheets', dataSheetRoutes);
apiRouter.use('/painting-schemes', paintingSchemeRoutes); app.use('/api/yield-studies', yieldStudyRoutes);
apiRouter.use('/application-records', applicationRecordRoutes); app.use('/api/system-settings', systemSettingsRoutes);
apiRouter.use('/inspections', inspectionRoutes); app.use('/api/geometry-types', geometryTypeRoutes);
apiRouter.use('/', analysisRoutes); app.use('/api/stock', stockRoutes);
apiRouter.use('/datasheets', dataSheetRoutes); app.use('/api/notifications', notificationRoutes);
apiRouter.use('/yield-studies', yieldStudyRoutes); app.use('/api/instruments', instrumentRoutes);
apiRouter.use('/system-settings', systemSettingsRoutes); app.use('/api/messages', messageRoutes);
apiRouter.use('/geometry-types', geometryTypeRoutes); app.use('/api/backup', backupRoutes);
apiRouter.use('/stock', stockRoutes);
apiRouter.use('/notifications', notificationRoutes);
apiRouter.use('/instruments', instrumentRoutes);
apiRouter.use('/messages', messageRoutes);
apiRouter.use('/backup', backupRoutes);
app.use('/api', apiRouter);
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date(), auth: 'logto' }); res.json({ status: 'ok', timestamp: new Date() });
}); });
// SPA fallback - must be last // Serve frontend static files
app.use((req, res, next) => { const clientPath = path.join(process.cwd(), 'dist', 'client');
if (req.path.startsWith('/api')) { if (fs.existsSync(clientPath)) {
return next(); // Do not return index.html for API 404s app.use(express.static(clientPath));
} app.use((req, res, next) => {
res.sendFile(path.join(distPath, 'index.html')); if (!req.path.startsWith('/api') && !req.path.startsWith('/uploads')) {
}); res.sendFile(path.join(clientPath, 'index.html'));
} else {
// Error handler next();
// eslint-disable-next-line @typescript-eslint/no-unused-vars }
app.use((err: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => { });
console.error('Express Error:', err); }
if (err.message === 'Not allowed by CORS') {
return res.status(403).json({ error: 'CORS error' });
}
res.status(500).json({ error: 'Internal server error' });
});
export default app; export default app;
+20 -10
View File
@@ -1,16 +1,26 @@
import { supabase } from './supabase.js'; import pg from 'pg';
const { Pool } = pg;
import dotenv from 'dotenv';
dotenv.config();
export const pool = new Pool({
host: process.env.DB_HOST || 'supabase-db',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'Xz0oyb6ArGYG5uAVTVwcvJxRrMuT7EIJ',
database: process.env.DB_NAME || 'postgres',
ssl: false
});
export const connectDB = async () => { export const connectDB = async () => {
try { try {
const { data, error } = await supabase.from('users').select('count'); const client = await pool.connect();
console.log('✅ Postgres (Supabase) connected successfully');
if (error) { client.release();
console.error('❌ Erro ao conectar no Supabase:', error);
throw error;
}
console.log('✅ Conectado ao Supabase (schema: gpi)');
} catch (error) { } catch (error) {
console.error('❌ Erro de conexão:', error); console.error('❌ Postgres connection error:', error);
} }
}; };
export const query = (text: string, params?: any[]) => pool.query(text, params);
+18
View File
@@ -0,0 +1,18 @@
import pg from 'pg';
const { Pool } = pg;
import dotenv from 'dotenv';
dotenv.config();
const pool = new Pool({
host: process.env.DB_HOST || 'supabase-db',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'Xz0oyb6ArGYG5uAVTVwcvJxRrMuT7EIJ',
database: process.env.DB_NAME || 'postgres',
ssl: false // Internal network usually doesn't need SSL
});
export const query = (text: string, params?: any[]) => pool.query(text, params);
export default pool;
-73
View File
@@ -1,73 +0,0 @@
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
dotenv.config();
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error('❌ SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be provided in environment variables');
}
export const GPI_SCHEMA = 'gpi';
export const supabase = createClient(supabaseUrl, supabaseServiceKey, {
db: {
schema: GPI_SCHEMA
},
auth: {
autoRefreshToken: false,
persistSession: false
}
});
export async function queryGpi(table: string, query?: any) {
let dbQuery = supabase.from(table).select('*');
if (query) {
if (query.filter) {
Object.entries(query.filter).forEach(([key, value]) => {
dbQuery = dbQuery.eq(key, value);
});
}
if (query.order) {
dbQuery = dbQuery.order(query.order.by || 'created_at', { ascending: query.order.asc ?? false });
}
if (query.limit) {
dbQuery = dbQuery.limit(query.limit);
}
if (query.offset) {
dbQuery = dbQuery.range(query.offset, query.offset + (query.limit || 10) - 1);
}
}
return await dbQuery;
}
export async function insertGpi(table: string, data: any) {
return await supabase.from(table).insert(data).select();
}
export async function updateGpi(table: string, id: string, data: any) {
return await supabase.from(table).update(data).eq('id', id).select();
}
export async function deleteGpi(table: string, id: string) {
return await supabase.from(table).delete().eq('id', id);
}
export async function findOneGpi(table: string, filters: Record<string, any>) {
let query = supabase.from(table).select('*');
Object.entries(filters).forEach(([key, value]) => {
query = query.eq(key, value);
});
const { data, error } = await query.single();
if (error && error.code !== 'PGRST116') {
throw error;
}
return data;
}
+1 -5
View File
@@ -128,12 +128,8 @@ export const getProjectAnalysis = async (req: Request, res: Response) => {
res.json(analysis); res.json(analysis);
} catch (error: unknown) { } catch (error: unknown) {
console.error('CRITICAL: Error in getProjectAnalysis controller:', error);
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ res.status(500).json({ error: message });
error: message,
stack: error instanceof Error ? error.stack : undefined
});
} }
}; };
@@ -1,11 +1,11 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as appRecordService from '../services/applicationRecordService.js'; import * as appRecordService from '../services/applicationRecordService.js';
import '../middleware/authMiddleware.js'; // Ensure type augmentation import '../middleware/roleMiddleware.js'; // Ensure type augmentation
export const createApplicationRecord = async (req: Request, res: Response) => { export const createApplicationRecord = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const createdBy = req.appUser?.email || 'guest'; const createdBy = req.appUser?.externalId;
const record = await appRecordService.createApplicationRecord({ ...req.body, organizationId, createdBy }); const record = await appRecordService.createApplicationRecord({ ...req.body, organizationId, createdBy });
res.status(201).json(record); res.status(201).json(record);
} catch (error: unknown) { } catch (error: unknown) {
@@ -29,10 +29,17 @@ export const getApplicationRecordsByProject = async (req: Request, res: Response
export const updateApplicationRecord = async (req: Request, res: Response) => { export const updateApplicationRecord = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.externalId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const record = await appRecordService.updateApplicationRecord( const record = await appRecordService.updateApplicationRecord(
req.params.id as string, req.params.id as string,
req.body req.body,
organizationId,
userId,
userRole as any,
isDeveloper
); );
if (!record) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' }); if (!record) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.json(record); res.json(record);
@@ -44,8 +51,17 @@ export const updateApplicationRecord = async (req: Request, res: Response) => {
export const deleteApplicationRecord = async (req: Request, res: Response) => { export const deleteApplicationRecord = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.externalId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const success = await appRecordService.deleteApplicationRecord( const success = await appRecordService.deleteApplicationRecord(
req.params.id as string req.params.id as string,
organizationId,
userId,
userRole as any,
isDeveloper
); );
if (!success) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' }); if (!success) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.status(204).send(); res.status(204).send();
+127
View File
@@ -0,0 +1,127 @@
import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { query } from '../config/database.js';
import { v4 as uuidv4 } from 'uuid';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod';
export const register = async (req: Request, res: Response): Promise<void> => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
res.status(400).json({ error: 'Todos os campos são obrigatórios' });
return;
}
const existingRes = await query('SELECT id FROM gpi.users WHERE email = $1', [email]);
if (existingRes.rows.length > 0) {
res.status(400).json({ error: 'Email já cadastrado' });
return;
}
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
// Gere um fakeAuthId para manter compatibilidade com sistemas que esperam um externalId
const fakeAuthId = `user_${uuidv4().replace(/-/g, '')}`;
const insertRes = await query(
`INSERT INTO gpi.users (name, email, clerk_id, role, is_banned, updated_at)
VALUES ($1, $2, $3, 'user', false, NOW()) RETURNING *`,
[name, email, fakeAuthId]
);
const newUser = insertRes.rows[0];
// Se houver uma coluna de password_hash no banco (precisamos garantir que exista)
try {
await query('UPDATE gpi.users SET password_hash = $1 WHERE id = $2', [passwordHash, newUser.id]);
} catch (e) {
console.warn('Could not update password_hash, check if column exists', e);
}
const token = jwt.sign(
{ userId: newUser.id, externalId: newUser.clerk_id, role: newUser.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({
message: 'Usuário criado com sucesso',
token,
user: { id: newUser.id, name: newUser.name, email: newUser.email, role: newUser.role, externalId: newUser.clerk_id }
});
} catch (error) {
console.error('Register Error:', error);
res.status(500).json({ error: 'Erro no servidor' });
}
};
export const login = async (req: Request, res: Response): Promise<void> => {
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({ error: 'Email e senha são obrigatórios' });
return;
}
const userRes = await query('SELECT * FROM gpi.users WHERE email = $1', [email]);
const user = userRes.rows[0];
if (!user) {
res.status(400).json({ error: 'Usuário não encontrado' });
return;
}
// Recuperar password_hash de algum lugar se não estiver no select principal (alguns schemas escondem)
// No nosso caso a query SELECT * deve trazer se existir.
if (!user.password_hash) {
res.status(400).json({ error: 'Usuário sem senha definida ou método de login externo.' });
return;
}
const isMatch = await bcrypt.compare(password, user.password_hash);
if (!isMatch) {
res.status(400).json({ error: 'Credenciais inválidas' });
return;
}
const token = jwt.sign(
{ userId: user.id, externalId: user.clerk_id || user.logto_id, role: user.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(200).json({
message: 'Login realizado com sucesso',
token,
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
externalId: user.clerk_id || user.logto_id
}
});
} catch (error) {
console.error('Login Error:', error);
res.status(500).json({ error: 'Erro no servidor' });
}
};
export const getMe = async (req: Request, res: Response): Promise<void> => {
try {
if (!req.appUser) {
res.status(401).json({ error: 'Não autorizado' });
return;
}
res.status(200).json(req.appUser);
} catch (error) {
console.error('GetMe Error:', error);
res.status(500).json({ error: 'Erro no servidor' });
}
};
+171 -19
View File
@@ -1,7 +1,10 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as dataSheetService from '../services/dataSheetService.js'; import * as dataSheetService from '../services/dataSheetService.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js'; import fs from 'fs';
import { IAppUser } from '../middleware/authMiddleware.js'; import * as pdfExtractionService from '../services/pdfExtractionService.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
import { notificationService } from '../services/notificationService.js';
import * as fileService from '../services/fileService.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: IAppUser; appUser?: IAppUser;
@@ -11,9 +14,10 @@ export const getAllDataSheets = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const sheets = await dataSheetService.getAllDataSheets(organizationId); const sheets = await dataSheetService.getAllDataSheets(organizationId);
res.json(toCamelCase(sheets)); res.json(sheets);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
@@ -23,47 +27,195 @@ export const extractData = async (req: AuthRequest, res: Response) => {
if (!file) { if (!file) {
return res.status(400).json({ error: 'File is required' }); return res.status(400).json({ error: 'File is required' });
} }
res.json({ extracted: true });
const fileBuffer = fs.readFileSync(file.path);
const data = await pdfExtractionService.extractDataFromPdf(fileBuffer);
res.json({
...data,
tempFilePath: file.path
});
} catch (error: unknown) { } catch (error: unknown) {
res.json({ extracted: false }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const createDataSheet = async (req: AuthRequest, res: Response) => { export const createDataSheet = async (req: AuthRequest, res: Response) => {
try { try {
const file = req.file;
const {
name, manufacturer, type, solidsVolume, density,
mixingRatio, mixingRatioWeight, mixingRatioVolume,
yieldTheoretical, dftReference, yieldFactor,
wftMin, wftMax, dftMin, dftMax, reducer, dilution,
notes, fileUrl,
manufacturerCode, minStock, typicalApplication
} = req.body;
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const payload = { ...req.body, organization_id: organizationId };
const newSheet = await dataSheetService.createDataSheet(toSnakeCase(payload)); let fileId: string | undefined = undefined;
res.status(201).json(toCamelCase(newSheet)); let finalFileUrl = fileUrl || '';
if (file) {
const buffer = fs.readFileSync(file.path);
const savedFile = await fileService.saveFile(file.originalname, file.mimetype, buffer);
fileId = savedFile.id;
finalFileUrl = savedFile.id; // Using ID as reference
try {
fs.unlinkSync(file.path);
} catch (error) {
console.warn('Failed to delete temp file:', file.path, error);
}
}
const newSheet = await dataSheetService.createDataSheet({
name,
manufacturer,
manufacturerCode,
type,
minStock: minStock ? Number(minStock) : undefined,
typicalApplication,
fileUrl: finalFileUrl,
fileId: fileId,
solidsVolume: solidsVolume ? Number(solidsVolume) : undefined,
density: density ? Number(density) : undefined,
mixingRatio,
mixingRatioWeight,
mixingRatioVolume,
yieldTheoretical: yieldTheoretical ? Number(yieldTheoretical) : undefined,
dftReference: dftReference ? Number(dftReference) : undefined,
yieldFactor: yieldFactor ? Number(yieldFactor) : undefined,
wftMin: wftMin ? Number(wftMin) : undefined,
wftMax: wftMax ? Number(wftMax) : undefined,
dftMin: dftMin ? Number(dftMin) : undefined,
dftMax: dftMax ? Number(dftMax) : undefined,
reducer,
dilution: dilution ? Number(dilution) : undefined,
notes,
organizationId
});
if (organizationId) {
await notificationService.create({
organizationId,
title: 'Nova Ficha Técnica',
message: `A ficha técnica "${name}" (${manufacturer}) foi adicionada à biblioteca.`,
type: 'info',
metadata: { dataSheetId: newSheet.id, triggerType: 'datasheet_created' }
});
}
res.status(201).json(newSheet);
} catch (error: unknown) { } catch (error: unknown) {
res.status(400).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error creating datasheet:', error);
res.status(500).json({ error: message });
} }
}; };
export const deleteDataSheet = async (req: AuthRequest, res: Response) => { export const deleteDataSheet = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
await dataSheetService.deleteDataSheet(id as string); const organizationId = req.appUser?.organizationId;
res.status(204).send(); const success = await dataSheetService.deleteDataSheet(id as string, organizationId);
if (success) {
res.status(204).send();
} else {
res.status(404).json({ error: 'Data sheet not found' });
}
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updateDataSheet = async (req: AuthRequest, res: Response) => { export const updateDataSheet = async (req: AuthRequest, res: Response) => {
try { try {
const id = req.params.id as string; const id = req.params.id as string;
const updatedSheet = await dataSheetService.updateDataSheet(id, toSnakeCase(req.body)); const file = req.file;
res.json(toCamelCase(updatedSheet || req.body)); const organizationId = req.appUser?.organizationId;
const {
name, manufacturer, type, solidsVolume, density,
mixingRatio, mixingRatioWeight, mixingRatioVolume,
yieldTheoretical, dftReference, yieldFactor,
wftMin, wftMax, dftMin, dftMax, reducer, dilution,
notes, fileUrl
} = req.body;
const updates: any = {
name,
manufacturer,
type,
notes,
solidsVolume: solidsVolume ? Number(solidsVolume) : undefined,
density: density ? Number(density) : undefined,
yieldTheoretical: yieldTheoretical ? Number(yieldTheoretical) : undefined,
dftReference: dftReference ? Number(dftReference) : undefined,
yieldFactor: yieldFactor ? Number(yieldFactor) : undefined,
wftMin: wftMin ? Number(wftMin) : undefined,
wftMax: wftMax ? Number(wftMax) : undefined,
dftMin: dftMin ? Number(dftMin) : undefined,
dftMax: dftMax ? Number(dftMax) : undefined,
reducer,
dilution: dilution ? Number(dilution) : undefined,
mixingRatio,
mixingRatioWeight,
mixingRatioVolume
};
if (file) {
const buffer = fs.readFileSync(file.path);
const savedFile = await fileService.saveFile(file.originalname, file.mimetype, buffer);
updates.fileId = savedFile.id;
updates.fileUrl = savedFile.id;
try {
fs.unlinkSync(file.path);
} catch (error) {
console.warn('Failed to delete temp file:', file.path, error);
}
} else if (fileUrl) {
updates.fileUrl = String(fileUrl);
}
const updatedSheet = await dataSheetService.updateDataSheet(id, updates, organizationId);
if (updatedSheet) {
res.json(updatedSheet);
} else {
res.status(404).json({ error: 'Data sheet not found' });
}
} catch (error: unknown) { } catch (error: unknown) {
res.status(400).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error updating datasheet:', error);
res.status(500).json({ error: message });
} }
}; };
export const getFile = async (req: AuthRequest, res: Response) => { export const getFile = async (req: Request, res: Response) => {
try { try {
const id = req.params.id as string;
// Check if it's a UUID
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
const fileDoc = await fileService.getFileById(id);
if (fileDoc) {
res.set('Content-Type', fileDoc.content_type || 'application/pdf');
res.set('Content-Disposition', `inline; filename="${fileDoc.filename}"`);
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=3600');
return res.send(fileDoc.data);
}
}
res.status(404).json({ error: 'File not found' }); res.status(404).json({ error: 'File not found' });
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: 'File not found' }); const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error getting file:', error);
res.status(500).json({ error: message });
} }
}; };
@@ -1,7 +1,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import { query } from '../config/database.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js';
// Default geometry types to seed if none exist
const DEFAULT_TYPES = [ const DEFAULT_TYPES = [
{ name: 'Guarda-corpo/escada', efficiencyLoss: 20 }, { name: 'Guarda-corpo/escada', efficiencyLoss: 20 },
{ name: 'Vigas leves', efficiencyLoss: 20 }, { name: 'Vigas leves', efficiencyLoss: 20 },
@@ -19,61 +19,108 @@ const DEFAULT_TYPES = [
export const getAllnames = async (req: Request, res: Response) => { export const getAllnames = async (req: Request, res: Response) => {
try { try {
const { data, error } = await supabase.from('geometry_types').select('*'); const organizationId = req.appUser?.organizationId;
if (error && error.code !== '42P01') throw error; const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
res.json(toCamelCase(data || []));
} catch (error: unknown) { if (!organizationId && !isGlobalAdmin) {
res.json(DEFAULT_TYPES); return res.status(400).json({ error: 'Organization ID missing' });
}
let sql = 'SELECT * FROM gpi.geometry_types';
const params: any[] = [];
if (!isGlobalAdmin) {
sql += ' WHERE organization_id = $1';
params.push(organizationId);
}
sql += ' ORDER BY name ASC';
const result = await query(sql, params);
let types = result.rows;
// Auto-seed if empty for org
if (types.length === 0 && organizationId && !isGlobalAdmin) {
for (const t of DEFAULT_TYPES) {
await query(
'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
[organizationId, t.name, t.efficiencyLoss]
);
}
const reFetch = await query('SELECT * FROM gpi.geometry_types WHERE organization_id = $1 ORDER BY name ASC', [organizationId]);
types = reFetch.rows;
}
res.json(types);
} catch (error: any) {
console.error('[GeometryType] Error:', error);
res.status(500).json({ error: error.message });
} }
}; };
export const restoreDefaults = async (req: Request, res: Response) => { export const restoreDefaults = async (req: Request, res: Response) => {
try { try {
res.json(DEFAULT_TYPES); const organizationId = req.appUser?.organizationId;
} catch (error: unknown) { if (!organizationId) return res.status(400).json({ error: 'Organization ID missing' });
res.json(DEFAULT_TYPES);
await query('DELETE FROM gpi.geometry_types WHERE organization_id = $1', [organizationId]);
for (const t of DEFAULT_TYPES) {
await query(
'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3)',
[organizationId, t.name, t.efficiencyLoss]
);
}
const result = await query('SELECT * FROM gpi.geometry_types WHERE organization_id = $1 ORDER BY name ASC', [organizationId]);
res.json(result.rows);
} catch (error: any) {
res.status(500).json({ error: error.message });
} }
}; };
export const createType = async (req: Request, res: Response) => { export const createType = async (req: Request, res: Response) => {
try { try {
const payload = toSnakeCase({ const organizationId = req.appUser?.organizationId;
...req.body, const { name, efficiencyLoss } = req.body;
organizationId: (req as any).appUser?.organizationId
});
const { data, error } = await supabase const result = await query(
.from('geometry_types') 'INSERT INTO gpi.geometry_types (organization_id, name, efficiency_loss) VALUES ($1, $2, $3) RETURNING *',
.insert(payload) [organizationId, name, Number(efficiencyLoss) || 0]
.select() );
.single(); res.status(201).json(result.rows[0]);
if (error) throw error; } catch (error: any) {
res.status(201).json(toCamelCase(data)); if (error.code === '23505') return res.status(409).json({ error: 'Already exists' });
} catch (error: unknown) { res.status(500).json({ error: error.message });
res.json(req.body);
} }
}; };
export const updateType = async (req: Request, res: Response) => { export const updateType = async (req: Request, res: Response) => {
try { try {
const { data, error } = await supabase const { id } = req.params;
.from('geometry_types') const organizationId = req.appUser?.organizationId;
.update(toSnakeCase(req.body)) const { name, efficiencyLoss } = req.body;
.eq('id', req.params.id)
.select() const result = await query(
.single(); 'UPDATE gpi.geometry_types SET name = $1, efficiency_loss = $2, updated_at = NOW() WHERE id = $3 AND organization_id = $4 RETURNING *',
if (error) throw error; [name, Number(efficiencyLoss), id, organizationId]
res.json(toCamelCase(data)); );
} catch (error: unknown) {
res.json(req.body); if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(result.rows[0]);
} catch (error: any) {
res.status(500).json({ error: error.message });
} }
}; };
export const deleteType = async (req: Request, res: Response) => { export const deleteType = async (req: Request, res: Response) => {
try { try {
await supabase.from('geometry_types').delete().eq('id', req.params.id); const { id } = req.params;
res.status(204).send(); const organizationId = req.appUser?.organizationId;
} catch (error: unknown) {
const result = await query('DELETE FROM gpi.geometry_types WHERE id = $1 AND organization_id = $2', [id, organizationId]);
if (result.rowCount === 0) return res.status(404).json({ error: 'Not found' });
res.status(204).send(); res.status(204).send();
} catch (error: any) {
res.status(500).json({ error: error.message });
} }
}; };
+57 -29
View File
@@ -1,34 +1,30 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as inspectionService from '../services/inspectionService.js'; import * as inspectionService from '../services/inspectionService.js';
import { notificationService } from '../services/notificationService.js'; import { notificationService } from '../services/notificationService.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js'; import * as fileService from '../services/fileService.js';
import fs from 'fs';
export const createInspection = async (req: Request, res: Response) => { export const createInspection = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const createdBy = req.appUser?.email || 'guest'; const createdBy = req.appUser?.externalId;
const inspection = await inspectionService.createInspection({
const payload = toSnakeCase({
...req.body, ...req.body,
organizationId, organizationId,
createdBy createdBy
}); });
const inspection = await inspectionService.createInspection(payload);
if (req.body.appearance === 'rejected' && organizationId) { if (req.body.appearance === 'rejected' && organizationId) {
try { await notificationService.create({
await notificationService.create({ organizationId,
organizationId, title: 'Inspeção Reprovada',
title: 'Inspeção Reprovada', message: `Uma inspeção foi reprovada na obra (ID: ${req.body.projectId}).`,
message: `Uma inspeção foi reprovada na obra (ID: ${req.body.projectId}).`, type: 'error',
type: 'error', metadata: { inspectionId: inspection.id, projectId: req.body.projectId, triggerType: 'inspection_rejected' }
metadata: { inspectionId: inspection?.id, projectId: req.body.projectId, triggerType: 'inspection_rejected' } });
});
} catch (e) { /* ignore notification errors */ }
} }
res.status(201).json(toCamelCase(inspection)); res.status(201).json(inspection);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
@@ -40,20 +36,30 @@ export const getInspectionsByProject = async (req: Request, res: Response) => {
const { projectId } = req.params; const { projectId } = req.params;
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const inspections = await inspectionService.getInspectionsByProject(projectId as string, organizationId); const inspections = await inspectionService.getInspectionsByProject(projectId as string, organizationId);
res.json(toCamelCase(inspections || [])); res.json(inspections);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updateInspection = async (req: Request, res: Response) => { export const updateInspection = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.externalId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const inspection = await inspectionService.updateInspection( const inspection = await inspectionService.updateInspection(
req.params.id as string, req.params.id as string,
toSnakeCase(req.body) req.body,
organizationId,
userId,
userRole as any,
isDeveloper
); );
if (!inspection) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' }); if (!inspection) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.json(toCamelCase(inspection)); res.json(inspection);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
@@ -62,22 +68,34 @@ export const updateInspection = async (req: Request, res: Response) => {
export const deleteInspection = async (req: Request, res: Response) => { export const deleteInspection = async (req: Request, res: Response) => {
try { try {
await inspectionService.deleteInspection(req.params.id as string); const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.externalId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const success = await inspectionService.deleteInspection(
req.params.id as string,
organizationId,
userId,
userRole as any,
isDeveloper
);
if (!success) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
res.status(204).send(); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const getAllInspections = async (req: Request, res: Response) => { export const getAllInspections = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const inspections = organizationId const inspections = await inspectionService.getAllInspections(organizationId);
? await inspectionService.getInspectionsByOrganization(organizationId) res.json(inspections);
: await inspectionService.getInspectionStats();
res.json(toCamelCase(inspections || []));
} catch (error: unknown) { } catch (error: unknown) {
res.json({ total: 0, inspections: [] }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
@@ -86,10 +104,20 @@ export const uploadPhoto = async (req: Request, res: Response) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' }); return res.status(400).json({ error: 'No file uploaded' });
} }
const fileUrl = `/uploads/${req.file.filename}`;
const buffer = fs.readFileSync(req.file.path);
const savedFile = await fileService.saveFile(req.file.originalname, req.file.mimetype, buffer);
// Clean up temp file
fs.unlinkSync(req.file.path);
// We'll use /api/datasheets/file/:id to serve these if we use the same endpoint
// or create a new general /api/files/:id
const fileUrl = `/api/datasheets/file/${savedFile.id}`;
res.json({ url: fileUrl }); res.json({ url: fileUrl });
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
} }
}; };
+38 -29
View File
@@ -1,50 +1,59 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import * as instrumentService from '../services/instrumentService.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
export const createInstrument = async (req: Request, res: Response) => { interface AuthRequest extends Request {
appUser?: IAppUser;
}
export const createInstrument = async (req: AuthRequest, res: Response) => {
try { try {
const { data, error } = await supabase const organizationId = req.appUser?.organizationId;
.from('instruments') const instrument = await instrumentService.createInstrument({
.insert({ ...req.body, organization_id: req.appUser?.organizationId }) ...req.body,
.select() organizationId
.single(); });
if (error) throw error; res.status(201).json(instrument);
res.status(201).json(data);
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const getInstruments = async (req: Request, res: Response) => { export const getInstruments = async (req: AuthRequest, res: Response) => {
try { try {
const { data, error } = await supabase.from('instruments').select('*'); const organizationId = req.appUser?.organizationId;
if (error && error.code !== '42P01') throw error; const { status } = req.query;
res.json(data || []); const instruments = await instrumentService.getInstruments(organizationId, status as string);
res.json(instruments);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updateInstrument = async (req: Request, res: Response) => { export const updateInstrument = async (req: AuthRequest, res: Response) => {
try { try {
const { data, error } = await supabase const { id } = req.params;
.from('instruments') const organizationId = req.appUser?.organizationId;
.update(req.body) const instrument = await instrumentService.updateInstrument(id, req.body, organizationId);
.eq('id', req.params.id) if (!instrument) return res.status(404).json({ error: 'Instrumento não encontrado.' });
.select() res.json(instrument);
.single();
if (error) throw error;
res.json(data);
} catch (error: unknown) { } catch (error: unknown) {
res.json(req.body); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const deleteInstrument = async (req: Request, res: Response) => { export const deleteInstrument = async (req: AuthRequest, res: Response) => {
try { try {
await supabase.from('instruments').delete().eq('id', req.params.id); const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const success = await instrumentService.deleteInstrument(id, organizationId);
if (!success) return res.status(404).json({ error: 'Instrumento não encontrado.' });
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
res.status(204).send(); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
+106 -89
View File
@@ -1,38 +1,47 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import { query } from '../config/database.js';
// Send a message // Send a message
export const sendMessage = async (req: Request, res: Response) => { export const sendMessage = async (req: Request, res: Response) => {
try { try {
const { toUserId, message } = req.body; const { toUserId, message } = req.body;
const fromUserId = req.appUser?.id; const fromUserId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId) { if (!organizationId) {
return res.status(400).json({ error: 'Organização não selecionada.' }); return res.status(400).json({ error: 'Organização não selecionada.' });
} }
if (!fromUserId) {
return res.status(401).json({ error: 'Usuário não autenticado.' });
}
if (!toUserId || !message) { if (!toUserId || !message) {
return res.status(400).json({ error: 'Destinatário e mensagem são obrigatórios.' }); return res.status(400).json({ error: 'Destinatário e mensagem são obrigatórios.' });
} }
const { data, error } = await supabase // Check if there's already a pending (unread) message from this user to that user
.from('messages') const existingRes = await query(
.insert({ 'SELECT * FROM gpi.messages WHERE organization_id = $1 AND from_user_id = $2 AND to_user_id = $3 AND is_read = false',
organization_id: organizationId, [organizationId, fromUserId, toUserId]
from_user_id: fromUserId, );
to_user_id: toUserId,
message,
is_read: false
})
.select()
.single();
if (error) throw error; if (existingRes.rows.length > 0) {
res.status(201).json(data); const updatedRes = await query(
} catch (error: any) { 'UPDATE gpi.messages SET message = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
[message, existingRes.rows[0].id]
);
return res.json(updatedRes.rows[0]);
}
const insertRes = await query(
`INSERT INTO gpi.messages (organization_id, from_user_id, to_user_id, message)
VALUES ($1, $2, $3, $4) RETURNING *`,
[organizationId, fromUserId, toUserId, message]
);
res.status(201).json(insertRes.rows[0]);
} catch (error) {
console.error('Error sending message:', error); console.error('Error sending message:', error);
res.status(500).json({ error: 'Erro ao enviar mensagem.' }); res.status(500).json({ error: 'Erro ao enviar mensagem.' });
} }
@@ -41,24 +50,29 @@ export const sendMessage = async (req: Request, res: Response) => {
// Get unread messages for current user // Get unread messages for current user
export const getUnreadMessages = async (req: Request, res: Response) => { export const getUnreadMessages = async (req: Request, res: Response) => {
try { try {
const toUserId = req.appUser?.id; const toUserId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId) { if (!organizationId || !toUserId) {
return res.status(400).json({ error: 'Organização não selecionada.' }); return res.status(400).json({ error: 'Contexto incompleto.' });
} }
const { data, error } = await supabase const sql = `
.from('messages') SELECT m.*, u.name as "fromUserName", u.email as "fromUserEmail"
.select('*') FROM gpi.messages m
.eq('organization_id', organizationId) LEFT JOIN gpi.users u ON m.from_user_id = u.clerk_id OR m.from_user_id = u.logto_id
.eq('to_user_id', toUserId) WHERE m.organization_id = $1 AND m.to_user_id = $2 AND m.is_read = false AND m.is_archived = false AND m.is_deleted_by_recipient = false
.eq('is_read', false) ORDER BY m.created_at DESC
.eq('is_archived', false); `;
const result = await query(sql, [organizationId, toUserId]);
const messages = result.rows.map(m => ({
...m,
fromUser: { name: m.fromUserName, email: m.fromUserEmail }
}));
if (error && error.code !== '42P01') throw error; res.json(messages);
res.json(data || []); } catch (error) {
} catch (error: any) {
console.error('Error getting unread messages:', error); console.error('Error getting unread messages:', error);
res.status(500).json({ error: 'Erro ao buscar mensagens.' }); res.status(500).json({ error: 'Erro ao buscar mensagens.' });
} }
@@ -68,21 +82,20 @@ export const getUnreadMessages = async (req: Request, res: Response) => {
export const markMessageAsRead = async (req: Request, res: Response) => { export const markMessageAsRead = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.id; const userId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase const result = await query(
.from('messages') 'UPDATE gpi.messages SET is_read = true, read_at = NOW() WHERE id = $1 AND organization_id = $2 AND to_user_id = $3 RETURNING *',
.update({ is_read: true, read_at: new Date().toISOString() }) [id, organizationId, userId]
.eq('id', id) );
.eq('to_user_id', userId)
.eq('organization_id', organizationId)
.select()
.single();
if (error) throw error; if (result.rows.length === 0) {
res.json(data); return res.status(404).json({ error: 'Mensagem não encontrada.' });
} catch (error: any) { }
res.json(result.rows[0]);
} catch (error) {
console.error('Error marking message as read:', error); console.error('Error marking message as read:', error);
res.status(500).json({ error: 'Erro ao marcar mensagem como lida.' }); res.status(500).json({ error: 'Erro ao marcar mensagem como lida.' });
} }
@@ -91,42 +104,52 @@ export const markMessageAsRead = async (req: Request, res: Response) => {
// Get my pending (unread) sent messages // Get my pending (unread) sent messages
export const getMyPendingMessages = async (req: Request, res: Response) => { export const getMyPendingMessages = async (req: Request, res: Response) => {
try { try {
const fromUserId = req.appUser?.id; const fromUserId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase if (!organizationId || !fromUserId) {
.from('messages') return res.status(400).json({ error: 'Contexto incompleto.' });
.select('*') }
.eq('organization_id', organizationId)
.eq('from_user_id', fromUserId)
.eq('is_read', false);
if (error && error.code !== '42P01') throw error; const sql = `
res.json(data || []); SELECT m.*, u.name as "toUserName", u.email as "toUserEmail"
} catch (error: any) { FROM gpi.messages m
LEFT JOIN gpi.users u ON m.to_user_id = u.clerk_id OR m.to_user_id = u.logto_id
WHERE m.organization_id = $1 AND m.from_user_id = $2 AND m.is_read = false
ORDER BY m.created_at DESC
`;
const result = await query(sql, [organizationId, fromUserId]);
const messages = result.rows.map(m => ({
...m,
toUser: { name: m.toUserName, email: m.toUserEmail }
}));
res.json(messages);
} catch (error) {
console.error('Error getting pending messages:', error); console.error('Error getting pending messages:', error);
res.status(500).json({ error: 'Erro ao buscar mensagens pendentes.' }); res.status(500).json({ error: 'Erro ao buscar mensagens pendentes.' });
} }
}; };
// Delete a message // Delete a message (only if unread and sender is the current user)
export const deleteMessage = async (req: Request, res: Response) => { export const deleteMessage = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.id; const userId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const { error } = await supabase const result = await query(
.from('messages') 'DELETE FROM gpi.messages WHERE id = $1 AND from_user_id = $2 AND organization_id = $3 AND is_read = false',
.delete() [id, userId, organizationId]
.eq('id', id) );
.eq('from_user_id', userId)
.eq('organization_id', organizationId)
.eq('is_read', false);
if (error) throw error; if (result.rowCount === 0) {
res.status(204).send(); return res.status(404).json({ error: 'Mensagem não encontrada ou já lida.' });
} catch (error: any) { }
res.json({ message: 'Mensagem deletada com sucesso.' });
} catch (error) {
console.error('Error deleting message:', error); console.error('Error deleting message:', error);
res.status(500).json({ error: 'Erro ao deletar mensagem.' }); res.status(500).json({ error: 'Erro ao deletar mensagem.' });
} }
@@ -136,21 +159,18 @@ export const deleteMessage = async (req: Request, res: Response) => {
export const archiveMessage = async (req: Request, res: Response) => { export const archiveMessage = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.id; const userId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase const result = await query(
.from('messages') 'UPDATE gpi.messages SET is_archived = true, is_read = true WHERE id = $1 AND to_user_id = $2 AND organization_id = $3 RETURNING *',
.update({ is_archived: true, is_read: true }) [id, userId, organizationId]
.eq('id', id) );
.eq('to_user_id', userId)
.eq('organization_id', organizationId)
.select()
.single();
if (error) throw error; if (result.rows.length === 0) return res.status(404).json({ error: 'Mensagem não encontrada.' });
res.json(data);
} catch (error: any) { res.json(result.rows[0]);
} catch (error) {
console.error('Error archiving message:', error); console.error('Error archiving message:', error);
res.status(500).json({ error: 'Erro ao arquivar mensagem.' }); res.status(500).json({ error: 'Erro ao arquivar mensagem.' });
} }
@@ -159,22 +179,19 @@ export const archiveMessage = async (req: Request, res: Response) => {
export const recipientDeleteMessage = async (req: Request, res: Response) => { export const recipientDeleteMessage = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const userId = req.appUser?.id; const userId = req.appUser?.externalId;
const organizationId = req.headers['x-organization-id'] as string; const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase const result = await query(
.from('messages') 'UPDATE gpi.messages SET is_deleted_by_recipient = true WHERE id = $1 AND to_user_id = $2 AND organization_id = $3 RETURNING *',
.update({ 'is_deleted_by_recipient': true }) [id, userId, organizationId]
.eq('id', id) );
.eq('to_user_id', userId)
.eq('organization_id', organizationId) if (result.rows.length === 0) return res.status(404).json({ error: 'Mensagem não encontrada.' });
.select()
.single();
if (error) throw error;
res.json({ message: 'Mensagem excluída com sucesso.' }); res.json({ message: 'Mensagem excluída com sucesso.' });
} catch (error: any) { } catch (error) {
console.error('Error deleting message:', error); console.error('Error deleting message:', error);
res.status(500).json({ error: 'Erro ao excluir mensagem.' }); res.status(500).json({ error: 'Erro ao excluir mensagem.' });
} }
}; };
@@ -1,116 +1,97 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import { notificationService } from '../services/notificationService.js';
export const notificationController = { export const notificationController = {
getUserNotifications: async (req: Request, res: Response) => { getUserNotifications: async (req: Request, res: Response) => {
try { try {
const organizationId = req.headers['x-organization-id'] as string || req.appUser?.organizationId; const organizationId = req.headers['x-organization-id'] as string;
const userId = req.headers['x-user-id'] as string; // Assumindo que temos o ID do usuário (externalId ou email)
// Se não tiver userId no header (ainda não implementado auth full), tentar pegar do query ou usar um fallback
// Nota: Idealmente o middleware de auth popula req.user. Vamos assumir que passamos x-user-id no frontend por enquanto.
if (!organizationId) { if (!organizationId) {
return res.json([]); // Return empty instead of error return res.status(400).json({ error: 'Organization ID is required' });
} }
const { data: notifications, error } = await supabase const notifications = await notificationService.getUserNotifications(
.from('notifications') userId,
.select('*') organizationId,
.eq('organization_id', organizationId) req.query.includeArchived === 'true'
.order('created_at', { ascending: false }); );
res.json(notifications);
if (error && error.code !== '42P01') throw error;
res.json(notifications || []);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json([]); res.status(500).json({ error: 'Error fetching notifications' });
} }
}, },
markAsRead: async (req: Request, res: Response) => { markAsRead: async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { error } = await supabase const notification = await notificationService.markAsRead(id as string);
.from('notifications') res.json(notification);
.update({ is_read: true })
.eq('id', id);
if (error && error.code !== '42P01') throw error;
res.json({ success: true });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ success: true }); res.status(500).json({ error: 'Error marking notification as read' });
} }
}, },
markAllAsRead: async (req: Request, res: Response) => { markAllAsRead: async (req: Request, res: Response) => {
try { try {
const organizationId = req.headers['x-organization-id'] as string || req.appUser?.organizationId; const organizationId = req.headers['x-organization-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!organizationId) { if (!organizationId) {
return res.json({ success: true }); return res.status(400).json({ error: 'Organization ID is required' });
} }
const { error } = await supabase await notificationService.markAllAsRead(userId, organizationId);
.from('notifications')
.update({ is_read: true })
.eq('organization_id', organizationId);
if (error && error.code !== '42P01') throw error;
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ success: true }); res.status(500).json({ error: 'Error marking all as read' });
} }
}, },
clearAll: async (req: Request, res: Response) => { clearAll: async (req: Request, res: Response) => {
try { try {
const organizationId = req.headers['x-organization-id'] as string || req.appUser?.organizationId; const organizationId = req.headers['x-organization-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!organizationId) { if (!organizationId) {
return res.json({ success: true }); return res.status(400).json({ error: 'Organization ID is required' });
} }
const { error } = await supabase await notificationService.clearAll(userId, organizationId);
.from('notifications')
.delete()
.eq('organization_id', organizationId);
if (error && error.code !== '42P01') throw error;
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ success: true }); res.status(500).json({ error: 'Error clearing all notifications' });
} }
}, },
archive: async (req: Request, res: Response) => { archive: async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { error } = await supabase const userId = req.headers['x-user-id'] as string;
.from('notifications') const notification = await notificationService.archive(id as string, userId);
.update({ is_archived: true }) res.json(notification);
.eq('id', id);
if (error && error.code !== '42P01') throw error;
res.json({ success: true });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ success: true }); res.status(500).json({ error: 'Error archiving notification' });
} }
}, },
delete: async (req: Request, res: Response) => { delete: async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { error } = await supabase const userId = req.headers['x-user-id'] as string;
.from('notifications') await notificationService.softDelete(id as string, userId);
.delete()
.eq('id', id);
if (error && error.code !== '42P01') throw error;
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.json({ success: true }); res.status(500).json({ error: 'Error deleting notification' });
} }
} }
}; };
@@ -1,44 +1,63 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as paintingSchemeService from '../services/paintingSchemeService.js'; import * as paintingSchemeService from '../services/paintingSchemeService.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js';
export const createPaintingScheme = async (req: Request, res: Response) => { export const createPaintingScheme = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId || 'e47e6210-4879-4e5b-bf21-9285d2713123'; const organizationId = req.appUser?.organizationId;
const schemeData = toSnakeCase({ ...req.body, organizationId }); console.log("Creating scheme with payload:", req.body);
const scheme = await paintingSchemeService.createPaintingScheme(schemeData); const scheme = await paintingSchemeService.createPaintingScheme({ ...req.body, organizationId });
res.status(201).json(toCamelCase(scheme)); console.log("Created scheme result:", scheme);
} catch (error: any) { res.status(201).json(scheme);
console.error('Error in createPaintingScheme:', error); } catch (error: unknown) {
res.status(400).json({ error: error?.message || error?.details || 'Erro ao cadastrar esquema de pintura' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const getPaintingSchemesByProject = async (req: Request, res: Response) => { export const getPaintingSchemesByProject = async (req: Request, res: Response) => {
try { try {
const { projectId } = req.params; const { projectId } = req.params;
const schemes = await paintingSchemeService.getPaintingSchemesByProject(projectId as string); const organizationId = req.appUser?.organizationId;
res.json(toCamelCase(schemes || [])); const schemes = await paintingSchemeService.getPaintingSchemesByProject(projectId as string, organizationId);
res.json(schemes);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updatePaintingScheme = async (req: Request, res: Response) => { export const updatePaintingScheme = async (req: Request, res: Response) => {
try { try {
const scheme = await paintingSchemeService.updatePaintingScheme(req.params.id as string, toSnakeCase(req.body)); const organizationId = req.appUser?.organizationId;
res.json(toCamelCase(scheme || req.body)); console.log("---------------------------------------------------");
console.log(`UPDATE REQUEST: ID=${req.params.id}`);
console.log(`User Org ID: ${organizationId}`);
console.log(`Payload keys: ${Object.keys(req.body)}`);
const scheme = await paintingSchemeService.updatePaintingScheme(req.params.id as string, req.body, organizationId);
console.log(`UPDATE RESULT: ${scheme ? 'SUCCESS' : 'NULL (Doc not found or not matched)'}`);
if (scheme) {
console.log(`Updated Doc Coat: ${scheme.coat}`);
}
console.log("---------------------------------------------------");
res.json(scheme);
} catch (error: unknown) { } catch (error: unknown) {
res.status(400).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const deletePaintingScheme = async (req: Request, res: Response) => { export const deletePaintingScheme = async (req: Request, res: Response) => {
try { try {
await paintingSchemeService.deletePaintingScheme(req.params.id as string); const organizationId = req.appUser?.organizationId;
await paintingSchemeService.deletePaintingScheme(req.params.id as string, organizationId);
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
@@ -46,8 +65,9 @@ export const getAllPaintingSchemes = async (req: Request, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const schemes = await paintingSchemeService.getAllSchemes(organizationId); const schemes = await paintingSchemeService.getAllSchemes(organizationId);
res.json(toCamelCase(schemes || [])); res.json(schemes);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
+15 -12
View File
@@ -1,7 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as partService from '../services/partService.js'; import * as partService from '../services/partService.js';
import { IAppUser } from '../middleware/authMiddleware.js'; import { IAppUser } from '../middleware/roleMiddleware.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: IAppUser; appUser?: IAppUser;
@@ -9,12 +8,14 @@ interface AuthRequest extends Request {
export const createPart = async (req: AuthRequest, res: Response) => { export const createPart = async (req: AuthRequest, res: Response) => {
try { try {
console.log('[CREATE PART] Received data:', JSON.stringify(req.body, null, 2));
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const payload = toSnakeCase({ ...req.body, organizationId }); const part = await partService.createPart({ ...req.body, organizationId });
const part = await partService.createPart(payload); console.log('[CREATE PART] Success:', part);
res.status(201).json(toCamelCase(part)); res.status(201).json(part);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
console.error('[CREATE PART] Error:', message);
res.status(500).json({ error: message }); res.status(500).json({ error: message });
} }
}; };
@@ -25,7 +26,7 @@ export const getPartsByProject = async (req: AuthRequest, res: Response) => {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com'; const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const parts = await partService.getPartsByProject(projectId as string, organizationId, isGlobalAdmin); const parts = await partService.getPartsByProject(projectId as string, organizationId, isGlobalAdmin);
res.json(toCamelCase(parts || [])); res.json(parts);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
@@ -36,8 +37,8 @@ export const updatePart = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com'; const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const part = await partService.updatePart(req.params.id as string, toSnakeCase(req.body), organizationId, isGlobalAdmin); const part = await partService.updatePart(req.params.id as string, req.body, organizationId, isGlobalAdmin);
res.json(toCamelCase(part)); res.json(part);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
@@ -46,7 +47,9 @@ export const updatePart = async (req: AuthRequest, res: Response) => {
export const deletePart = async (req: AuthRequest, res: Response) => { export const deletePart = async (req: AuthRequest, res: Response) => {
try { try {
await partService.deletePart(req.params.id as string); const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
await partService.deletePart(req.params.id as string, organizationId, isGlobalAdmin);
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
@@ -57,9 +60,9 @@ export const deletePart = async (req: AuthRequest, res: Response) => {
export const getAllParts = async (req: AuthRequest, res: Response) => { export const getAllParts = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
if (!organizationId) return res.json([]); const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const parts = await partService.getPartsByOrganization(organizationId); const parts = await partService.getAllParts(organizationId, isGlobalAdmin);
res.json(toCamelCase(parts || [])); res.json(parts);
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
+32 -30
View File
@@ -1,8 +1,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import * as projectService from '../services/projectService.js'; import * as projectService from '../services/projectService.js';
import { IAppUser } from '../middleware/authMiddleware.js'; import { IAppUser } from '../middleware/roleMiddleware.js';
import { notificationService } from '../services/notificationService.js'; import { notificationService } from '../services/notificationService.js';
import { toCamelCase } from '../utils/caseMapper.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: IAppUser; appUser?: IAppUser;
@@ -12,10 +11,9 @@ export const createProject = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const project = await projectService.createProject({ ...req.body, organizationId }); const project = await projectService.createProject({ ...req.body, organizationId });
res.status(201).json(toCamelCase(project)); res.status(201).json(project);
} catch (error: any) { } catch (error: unknown) {
console.error('Error in createProject controller:', error); const message = error instanceof Error ? error.message : 'Unknown error';
const message = error?.message || error?.details || (typeof error === 'string' ? error : JSON.stringify(error)) || 'Erro ao criar projeto';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
} }
}; };
@@ -26,10 +24,9 @@ export const getAllProjects = async (req: AuthRequest, res: Response) => {
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com'; const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const { status } = req.query; const { status } = req.query;
const projects = await projectService.getAllProjects(organizationId, isGlobalAdmin, status as string); const projects = await projectService.getAllProjects(organizationId, isGlobalAdmin, status as string);
res.json(toCamelCase(projects)); res.json(projects);
} catch (error: any) { } catch (error: unknown) {
console.error('Error in getAllProjects controller:', error); const message = error instanceof Error ? error.message : 'Unknown error';
const message = error?.message || error?.details || (typeof error === 'string' ? error : JSON.stringify(error));
res.status(500).json({ error: message }); res.status(500).json({ error: message });
} }
}; };
@@ -39,9 +36,9 @@ export const archiveProject = async (req: AuthRequest, res: Response) => {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com'; const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const project = await projectService.archiveProject(req.params.id as string, organizationId, isGlobalAdmin); const project = await projectService.archiveProject(req.params.id as string, organizationId, isGlobalAdmin);
res.json(toCamelCase(project)); res.json(project);
} catch (error: any) { } catch (error: unknown) {
const message = error?.message || error?.details || 'Erro ao arquivar projeto'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
} }
}; };
@@ -50,19 +47,21 @@ export const getDashboardProjects = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const projects = await projectService.getDashboardProjects(organizationId); const projects = await projectService.getDashboardProjects(organizationId);
res.json(toCamelCase(projects)); res.json(projects);
} catch (error: any) { } catch (error: unknown) {
const message = error?.message || error?.details || 'Erro ao obter projetos'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
} }
}; };
export const getProjectById = async (req: AuthRequest, res: Response) => { export const getProjectById = async (req: AuthRequest, res: Response) => {
try { try {
const project = await projectService.getProjectById(req.params.id as string); const organizationId = req.appUser?.organizationId;
res.json(toCamelCase(project)); const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
} catch (error: any) { const project = await projectService.getProjectById(req.params.id as string, organizationId, isGlobalAdmin);
const message = error?.message || error?.details || 'Projeto não encontrado'; res.json(project);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(404).json({ error: message }); res.status(404).json({ error: message });
} }
}; };
@@ -70,31 +69,34 @@ export const getProjectById = async (req: AuthRequest, res: Response) => {
export const updateProject = async (req: AuthRequest, res: Response) => { export const updateProject = async (req: AuthRequest, res: Response) => {
try { try {
const organizationId = req.appUser?.organizationId; const organizationId = req.appUser?.organizationId;
const project = await projectService.updateProject(req.params.id as string, req.body); const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const project = await projectService.updateProject(req.params.id as string, req.body, organizationId, isGlobalAdmin);
if (req.body.weightKg !== undefined && organizationId && project) { if (req.body.weightKg !== undefined && organizationId) {
await notificationService.create({ await notificationService.create({
organizationId, organizationId,
title: 'Atualização de Obra', title: 'Atualização de Obra',
message: `O peso da obra "${project.name}" foi atualizado para ${project.weight_kg}kg.`, message: `O peso da obra "${project.name}" foi atualizado para ${project.weightKg}kg.`,
type: 'info', type: 'info',
metadata: { projectId: project.id, triggerType: 'project_update' } metadata: { projectId: project.id, triggerType: 'project_update' }
}); });
} }
res.json(toCamelCase(project)); res.json(project);
} catch (error: any) { } catch (error: unknown) {
const message = error?.message || error?.details || 'Erro ao atualizar projeto'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
} }
}; };
export const deleteProject = async (req: AuthRequest, res: Response) => { export const deleteProject = async (req: AuthRequest, res: Response) => {
try { try {
await projectService.deleteProject(req.params.id as string); const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
await projectService.deleteProject(req.params.id as string, organizationId, isGlobalAdmin);
res.status(204).send(); res.status(204).send();
} catch (error: any) { } catch (error: unknown) {
const message = error?.message || error?.details || 'Erro ao excluir projeto'; const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message }); res.status(500).json({ error: message });
} }
}; };
+242 -106
View File
@@ -1,152 +1,288 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import * as stockItemService from '../services/stockItemService.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js'; import { IAppUser } from '../middleware/roleMiddleware.js';
import { IAppUser } from '../middleware/authMiddleware.js'; import { notificationService } from '../services/notificationService.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: IAppUser; appUser?: IAppUser;
} }
export const getStockItems = async (req: AuthRequest, res: Response) => {
try {
const { data: items, error: itemsError } = await supabase.from('stock_items').select('*');
if (itemsError && itemsError.code !== '42P01') throw itemsError;
if (!items || items.length === 0) return res.json([]);
// Get unique data sheet IDs
const dsIds = [...new Set(items.map(i => i.data_sheet_id).filter(Boolean))];
let dataSheets: any[] = [];
if (dsIds.length > 0) {
const { data: sheets, error: dsError } = await supabase
.from('technical_data_sheets')
.select('*')
.in('id', dsIds);
if (!dsError) dataSheets = sheets || [];
}
// Map data sheets to a lookup object
const dsMap = Object.fromEntries(dataSheets.map(ds => [ds.id, ds]));
// Merge and convert to camelCase
const enrichedItems = items.map(item => ({
...item,
data_sheet_id: dsMap[item.data_sheet_id] || item.data_sheet_id
}));
res.json(toCamelCase(enrichedItems));
} catch (error: unknown) {
console.error('Error fetching stock items:', error);
res.json([]);
}
};
export const getStockItemById = async (req: AuthRequest, res: Response) => {
try {
const { data, error } = await supabase.from('stock_items').select('*').eq('id', req.params.id).single();
if (error) throw error;
res.json(toCamelCase(data));
} catch (error: unknown) {
res.json(null);
}
};
export const getStockMovements = async (req: AuthRequest, res: Response) => {
try {
const { data, error } = await supabase.from('stock_movements').select('*').eq('stock_item_id', req.params.id);
if (error && error.code !== '42P01') throw error;
res.json(toCamelCase(data || []));
} catch (error: unknown) {
res.json([]);
}
};
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
try {
const { data, error } = await supabase.from('stock_audit_logs').select('*').eq('stock_item_id', req.params.id);
if (error && error.code !== '42P01') throw error;
res.json(toCamelCase(data || []));
} catch (error: unknown) {
res.json([]);
}
};
export const createStockItem = async (req: AuthRequest, res: Response) => { export const createStockItem = async (req: AuthRequest, res: Response) => {
try { try {
const itemData = toSnakeCase({ ...req.body, organizationId: req.appUser?.organizationId }); const organizationId = req.appUser?.organizationId;
const { data, error } = await supabase.from('stock_items').insert(itemData).select().single(); const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
if (error) throw error; const {
res.status(201).json(toCamelCase(data)); dataSheetId, rrNumber, batchNumber, quantity, unit,
expirationDate, notes, color, invoiceNumber, receivedBy, minStock
} = req.body;
if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) {
return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' });
}
const savedItem = await stockItemService.createStockItem({
...req.body,
organizationId,
createdBy: req.appUser?.externalId,
quantity: Number(quantity),
minStock: Number(minStock) || 0
});
await stockItemService.createStockMovement({
organizationId,
createdBy: req.appUser?.externalId,
stockItemId: savedItem.id,
movementNumber: 1,
type: 'ENTRY',
quantity: Number(quantity),
responsible: userName,
notes: 'Abertura de Lote / Entrada Inicial'
});
if (organizationId) {
await notificationService.create({
organizationId,
title: 'Recebimento de Material',
message: `Recebido: ${quantity}${unit} de ${savedItem.rr_number} (Lote: ${batchNumber}).`,
type: 'info',
metadata: { stockItemId: savedItem.id, triggerType: 'stock_received' }
});
}
res.status(201).json(savedItem);
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updateStockItem = async (req: AuthRequest, res: Response) => { export const updateStockItem = async (req: AuthRequest, res: Response) => {
try { try {
const updateData = toSnakeCase(req.body); const { id } = req.params;
const { data, error } = await supabase.from('stock_items').update(updateData).eq('id', req.params.id).select().single(); const organizationId = req.appUser?.organizationId;
if (error) throw error; const { quantity, ...otherData } = req.body;
res.json(toCamelCase(data));
if (quantity !== undefined) {
return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' });
}
const updated = await stockItemService.updateStockItem(id, organizationId!, otherData);
if (!updated) return res.status(404).json({ error: 'Item não encontrado.' });
res.json(updated);
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const deleteStockItem = async (req: AuthRequest, res: Response) => {
try {
await supabase.from('stock_items').delete().eq('id', req.params.id);
res.status(204).send();
} catch (error: unknown) {
res.status(204).send();
}
};
export const adjustStock = async (req: AuthRequest, res: Response) => { export const adjustStock = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const updateData = toSnakeCase(req.body); const organizationId = req.appUser?.organizationId;
const { data, error } = await supabase.from('stock_items').update(updateData).eq('id', id).select().single(); const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
if (error) throw error; const { quantityDelta, reason } = req.body;
res.json(toCamelCase(data));
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
const item = await stockItemService.getStockItemById(id, organizationId!);
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
const newQuantity = Number(item.quantity) + Number(quantityDelta);
if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' });
await stockItemService.updateStockItemQuantity(id, newQuantity);
const lastNum = await stockItemService.getLatestMovementNumber(id);
await stockItemService.createStockMovement({
organizationId,
createdBy: req.appUser?.externalId,
stockItemId: id,
movementNumber: lastNum + 1,
type: 'ADJUSTMENT',
quantity: Number(quantityDelta),
responsible: userName,
reason
});
res.json({ ...item, quantity: newQuantity });
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const consumeStock = async (req: AuthRequest, res: Response) => { export const consumeStock = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { quantity } = req.body; const organizationId = req.appUser?.organizationId;
const { data: item, error: fetchError } = await supabase.from('stock_items').select('quantity').eq('id', id).single(); const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
if (fetchError) throw fetchError; const { quantityConsumed, requester, date } = req.body;
const newQuantity = (item.quantity || 0) - (quantity || 0); const item = await stockItemService.getStockItemById(id, organizationId!);
const { data, error } = await supabase.from('stock_items').update({ quantity: newQuantity }).eq('id', id).select().single(); if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
if (error) throw error;
if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' });
res.json(toCamelCase(data));
const newQuantity = item.quantity - Number(quantityConsumed);
await stockItemService.updateStockItemQuantity(id, newQuantity);
const lastNum = await stockItemService.getLatestMovementNumber(id);
await stockItemService.createStockMovement({
organizationId,
createdBy: req.appUser?.externalId,
stockItemId: id,
movementNumber: lastNum + 1,
type: 'CONSUMPTION',
quantity: -Number(quantityConsumed),
responsible: userName,
requester,
date: date || new Date()
});
res.json({ ...item, quantity: newQuantity });
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const deleteStockItem = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const success = await stockItemService.deleteStockItem(id, organizationId!);
if (!success) return res.status(404).json({ error: 'Item não encontrado.' });
res.status(204).send();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getStockItems = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const { dataSheetId } = req.query;
const items = await stockItemService.getStockItems(organizationId!, dataSheetId as string);
res.json(items);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getStockItemById = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const item = await stockItemService.getStockItemById(id, organizationId!);
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
res.json(item);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getStockMovements = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const movements = await stockItemService.getStockMovements(id, organizationId!);
res.json(movements);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const updateStockMovement = async (req: AuthRequest, res: Response) => { export const updateStockMovement = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { data, error } = await supabase.from('stock_movements').update(req.body).eq('id', id).select().single(); const organizationId = req.appUser?.organizationId;
if (error) throw error; const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
res.json(toCamelCase(data)); const userId = req.appUser?.externalId || 'system';
const oldMovement = await stockItemService.getStockMovementById(id, organizationId!);
if (!oldMovement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
const item = await stockItemService.getStockItemById(oldMovement.stock_item_id, organizationId!);
const quantityDiff = Number(req.body.quantity) - Number(oldMovement.quantity);
const newStockLevel = Number(item.quantity) + quantityDiff;
if (newStockLevel < 0) return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
await stockItemService.updateStockItemQuantity(item.id, newStockLevel);
await stockItemService.createAuditLog({
organizationId,
stockItemId: item.id,
movementId: id,
movementNumber: oldMovement.movement_number,
userId,
userName,
action: 'UPDATE',
details: `Edição de Movimentação: Qtd ${oldMovement.quantity} -> ${req.body.quantity}`,
oldValues: oldMovement,
newValues: req.body
});
const updated = await stockItemService.updateStockMovement(id, organizationId!, req.body);
res.json(updated);
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const deleteStockMovement = async (req: AuthRequest, res: Response) => { export const deleteStockMovement = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
await supabase.from('stock_movements').delete().eq('id', id); const organizationId = req.appUser?.organizationId;
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
const userId = req.appUser?.externalId || 'system';
const movement = await stockItemService.getStockMovementById(id, organizationId!);
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
const item = await stockItemService.getStockItemById(movement.stock_item_id, organizationId!);
const newStockLevel = Number(item.quantity) - Number(movement.quantity);
if (newStockLevel < 0) return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
await stockItemService.updateStockItemQuantity(item.id, newStockLevel);
await stockItemService.createAuditLog({
organizationId,
stockItemId: item.id,
movementId: id,
movementNumber: movement.movement_number,
userId,
userName,
action: 'DELETE',
details: `Exclusão de Movimentação: Qtd ${movement.quantity}`,
oldValues: movement
});
await stockItemService.deleteStockMovement(id, organizationId!);
res.status(204).send(); res.status(204).send();
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const logs = await stockItemService.getStockAuditLogs(id, organizationId!);
res.json(logs);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
@@ -1,112 +1,47 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import { query } from '../config/database.js';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
const DEFAULT_SETTINGS = {
settingsId: 'global',
appName: 'GPI',
appSubtitle: 'Gestão de Pintura Industrial'
};
export const getSettings = async (req: Request, res: Response) => { export const getSettings = async (req: Request, res: Response) => {
try { try {
const { data: settings, error } = await supabase const resSettings = await query('SELECT * FROM gpi.system_settings WHERE settings_id = $1', ['global']);
.from('system_settings') let settings = resSettings.rows[0];
.select('*')
.eq('key', 'global')
.single();
if (settings && settings.value) { if (!settings) {
const val = typeof settings.value === 'string' ? JSON.parse(settings.value) : settings.value; const insertRes = await query(
return res.json({ 'INSERT INTO gpi.system_settings (settings_id, app_name, app_subtitle) VALUES ($1, $2, $3) RETURNING *',
settingsId: 'global', ['global', 'GPI', 'Gestão de Pintura Industrial']
appName: val.appName || DEFAULT_SETTINGS.appName, );
appSubtitle: val.appSubtitle || DEFAULT_SETTINGS.appSubtitle, settings = insertRes.rows[0];
appLogoUrl: val.appLogoUrl || ''
});
} }
const { data: fallback } = await supabase res.json(settings);
.from('system_settings')
.select('*')
.limit(1)
.single();
if (fallback) {
if (fallback.value) {
const val = typeof fallback.value === 'string' ? JSON.parse(fallback.value) : fallback.value;
return res.json({
settingsId: 'global',
appName: val.appName || DEFAULT_SETTINGS.appName,
appSubtitle: val.appSubtitle || DEFAULT_SETTINGS.appSubtitle,
appLogoUrl: val.appLogoUrl || ''
});
}
return res.json({
settingsId: 'global',
appName: fallback.app_name || DEFAULT_SETTINGS.appName,
appSubtitle: fallback.app_subtitle || DEFAULT_SETTINGS.appSubtitle,
appLogoUrl: fallback.app_logo_url || ''
});
}
res.json(DEFAULT_SETTINGS);
} catch (error) { } catch (error) {
console.error('Error fetching system settings:', error); console.error('Error fetching system settings:', error);
res.json(DEFAULT_SETTINGS); res.status(500).json({ error: 'Erro ao buscar configurações do sistema' });
} }
}; };
export const updateSettings = async (req: Request, res: Response) => { export const updateSettings = async (req: Request, res: Response) => {
try { try {
const { appName, appSubtitle, appLogoUrl } = req.body; const { appName, appSubtitle, appLogoUrl } = req.body;
const orgId = req.appUser?.organizationId || 'e47e6210-4879-4e5b-bf21-9285d2713123';
const { data: existing } = await supabase const result = await query(
.from('system_settings') `INSERT INTO gpi.system_settings (settings_id, app_name, app_subtitle, app_logo_url, updated_by, updated_at)
.select('*') VALUES ('global', $1, $2, $3, $4, NOW())
.eq('key', 'global') ON CONFLICT (settings_id) DO UPDATE SET
.single(); app_name = EXCLUDED.app_name,
app_subtitle = EXCLUDED.app_subtitle,
app_logo_url = EXCLUDED.app_logo_url,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING *`,
[appName, appSubtitle, appLogoUrl, req.appUser?.email]
);
const valPayload = { res.json(result.rows[0]);
appName: appName || DEFAULT_SETTINGS.appName,
appSubtitle: appSubtitle || DEFAULT_SETTINGS.appSubtitle,
appLogoUrl: appLogoUrl || '',
updatedBy: req.appUser?.email || 'admin',
updatedAt: new Date().toISOString()
};
if (!existing) {
const { error } = await supabase
.from('system_settings')
.insert({
organization_id: orgId,
key: 'global',
value: valPayload
});
if (error) throw error;
} else {
const { error } = await supabase
.from('system_settings')
.update({
value: valPayload,
updated_at: new Date().toISOString()
})
.eq('id', existing.id);
if (error) throw error;
}
console.log(`⚙️ System Settings updated by ${req.appUser?.email}`);
res.json({
settingsId: 'global',
appName,
appSubtitle,
appLogoUrl
});
} catch (error) { } catch (error) {
console.error('Error updating system settings:', error); console.error('Error updating system settings:', error);
res.status(500).json({ error: 'Erro ao atualizar configurações do sistema' }); res.status(500).json({ error: 'Erro ao atualizar configurações do sistema' });
@@ -116,22 +51,12 @@ export const updateSettings = async (req: Request, res: Response) => {
export const serveLogo = async (req: Request, res: Response) => { export const serveLogo = async (req: Request, res: Response) => {
try { try {
const { filename } = req.params as { filename: string }; const { filename } = req.params as { filename: string };
const tmpPath = path.join(os.tmpdir(), 'uploads', filename);
const localPath = path.join(process.cwd(), 'uploads', filename); const localPath = path.join(process.cwd(), 'uploads', filename);
if (fs.existsSync(tmpPath)) { if (fs.existsSync(localPath)) {
return res.sendFile(tmpPath); res.sendFile(localPath);
} else if (fs.existsSync(localPath)) {
return res.sendFile(localPath);
} else { } else {
res.setHeader('Content-Type', 'image/svg+xml'); res.status(404).json({ error: 'Imagem não encontrada' });
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.send(`
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#6366f1"/>
<text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="48" font-weight="900" fill="#ffffff">G</text>
</svg>
`);
} }
} catch (error) { } catch (error) {
console.error('Error serving logo:', error); console.error('Error serving logo:', error);
@@ -144,7 +69,6 @@ export const uploadLogo = async (req: Request, res: Response) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' }); return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
} }
const fileUrl = `/api/system-settings/logo-image/${req.file.filename}`; const fileUrl = `/api/system-settings/logo-image/${req.file.filename}`;
res.json({ url: fileUrl }); res.json({ url: fileUrl });
} catch (error) { } catch (error) {
@@ -155,25 +79,8 @@ export const uploadLogo = async (req: Request, res: Response) => {
export const getGlobalUsers = async (req: Request, res: Response) => { export const getGlobalUsers = async (req: Request, res: Response) => {
try { try {
const { data: users, error } = await supabase const resUsers = await query('SELECT * FROM gpi.users ORDER BY created_at DESC');
.from('users') res.json(resUsers.rows);
.select('*')
.order('created_at', { ascending: false });
if (error && error.code !== '42P01') throw error;
const mappedUsers = (users || []).map((u: any) => ({
...u,
_id: u.id,
id: u.id,
name: u.name || u.email?.split('@')[0] || 'Usuário',
email: u.email || '',
role: u.role || 'user',
isBanned: u.is_banned ?? false,
createdAt: u.created_at
}));
res.json(mappedUsers);
} catch (error) { } catch (error) {
console.error('Error getting global users:', error); console.error('Error getting global users:', error);
res.status(500).json({ error: 'Erro ao buscar usuários globais.' }); res.status(500).json({ error: 'Erro ao buscar usuários globais.' });
@@ -182,47 +89,20 @@ export const getGlobalUsers = async (req: Request, res: Response) => {
export const getGlobalOrganizations = async (req: Request, res: Response) => { export const getGlobalOrganizations = async (req: Request, res: Response) => {
try { try {
const { data: organizations, error } = await supabase const sql = `
.from('organizations') SELECT
.select('*'); o.id as _id,
o.name,
if (error && error.code !== '42P01') throw error; o.is_banned as "isBanned",
COUNT(uo.user_id) as "memberCount",
if (!organizations || organizations.length === 0) { MAX(uo.updated_at) as "lastActive"
return res.json([]); FROM gpi.organizations o
} LEFT JOIN gpi.user_organizations uo ON o.id = uo.organization_id
GROUP BY o.id, o.name, o.is_banned
const orgsWithMembers = await Promise.all( ORDER BY "memberCount" DESC
organizations.map(async (org) => { `;
let members: any[] = []; const resOrgs = await query(sql);
try { res.json(resOrgs.rows);
const { data: mems } = await supabase
.from('user_organizations')
.select('*')
.eq('organization_id', org.id);
members = mems || [];
} catch {
members = [];
}
return {
...org,
_id: org.id,
id: org.id,
isBanned: org.is_banned ?? false,
members: members.map((m: any) => ({
userId: m.user_id || m.id,
name: m.name || m.user_name || 'Membro',
email: m.email || m.user_email || '',
role: m.role || 'user',
isBanned: m.is_banned ?? false
})),
memberCount: members?.length || 0
};
})
);
res.json(orgsWithMembers);
} catch (error) { } catch (error) {
console.error('Error getting global organizations:', error); console.error('Error getting global organizations:', error);
res.status(500).json({ error: 'Erro ao buscar organizações globais.' }); res.status(500).json({ error: 'Erro ao buscar organizações globais.' });
@@ -237,18 +117,14 @@ export const toggleOrganizationBan = async (req: Request, res: Response) => {
return res.status(400).json({ error: 'ID da organização é obrigatório.' }); return res.status(400).json({ error: 'ID da organização é obrigatório.' });
} }
const { data: org, error } = await supabase const resOrg = await query(
.from('organizations') 'UPDATE gpi.organizations SET is_banned = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
.update({ is_banned: isBanned }) [isBanned, organizationId]
.eq('id', organizationId) );
.select()
.single();
if (error) throw error; res.json(resOrg.rows[0]);
console.log(`Organization ${organizationId} ban status set to ${isBanned} by ${req.appUser?.email}`);
res.json(org);
} catch (error) { } catch (error) {
console.error('Error toggling organization ban:', error); console.error('Error toggling organization ban:', error);
res.status(500).json({ error: 'Erro ao atualizar status da organização.' }); res.status(500).json({ error: 'Erro ao atualizar status da organização.' });
} }
}; };
+82 -143
View File
@@ -1,97 +1,66 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import * as userService from '../services/userService.js';
interface AuthRequest extends Request { interface AuthRequest extends Request {
appUser?: any; appUser?: any;
} }
export const syncUser = async (req: AuthRequest, res: Response) => { export const syncUser = async (req: Request, res: Response) => {
try { try {
if (req.appUser) { const { externalId, email, name, organizationId, clerkRole } = req.body;
return res.json(req.appUser);
if (!externalId || !email || !name) {
return res.status(400).json({ error: 'externalId, email e name são obrigatórios.' });
} }
const { email, name, logto_id } = req.body; const user = await userService.syncUser({
externalId,
if (!email) { email,
return res.status(400).json({ error: 'Email é obrigatório para sincronização.' }); name,
} organizationId,
clerkRole
const { data: existingUser } = await supabase });
.from('users')
.select('*')
.eq('email', email)
.single();
let user;
if (!existingUser) {
const { data, error } = await supabase
.from('users')
.insert({
email,
name: name || email.split('@')[0],
logto_id,
role: 'guest'
})
.select()
.single();
if (error) throw error;
user = data;
} else {
if (logto_id && !existingUser.logto_id) {
const { data } = await supabase
.from('users')
.update({ logto_id })
.eq('id', existingUser.id)
.select()
.single();
user = data;
} else {
user = existingUser;
}
}
res.json(user); res.json(user);
} catch (error: any) { } catch (error) {
console.error('Error syncing user:', error); console.error('Error syncing user:', error);
res.status(500).json({ error: 'Erro ao sincronizar usuário: ' + error.message }); res.status(500).json({ error: 'Erro ao sincronizar usuário: ' + (error instanceof Error ? error.message : String(error)) });
} }
}; };
export const getCurrentUser = async (req: AuthRequest, res: Response) => { export const getCurrentUser = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.appUser) { if (!req.appUser) {
return res.json({ return res.status(404).json({ error: 'Usuário não encontrado.' });
id: '00000000-0000-0000-0000-000000000000',
email: 'guest@gpi.app',
name: 'Guest User',
role: 'user'
});
} }
res.json(req.appUser);
} catch (error: any) { const organizationId = req.headers['x-organization-id'] as string;
const user = await userService.getCurrentUser(req.appUser.clerk_id || req.appUser.logto_id || req.appUser.externalId, organizationId);
if (!user) {
return res.status(404).json({ error: 'Usuário não encontrado.' });
}
res.json(user);
} catch (error) {
console.error('Error getting current user:', error); console.error('Error getting current user:', error);
res.json(req.appUser || { id: 'guest-user', email: 'guest@gpi.app', role: 'user' }); res.status(500).json({ error: 'Erro ao buscar usuário.' });
} }
}; };
export const getAllUsers = async (req: Request, res: Response) => { export const getAllUsers = async (req: Request, res: Response) => {
try { try {
// Always return all users from users table for now const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase
.from('users') if (!organizationId) {
.select('*'); return res.status(400).json({ error: 'Organização não selecionada.' });
if (error) {
console.log('Error fetching users:', error.message);
return res.json([]);
} }
return res.json(data || []); const members = await userService.getAllUsersInOrg(organizationId);
} catch (error: any) { res.json(members);
} catch (error) {
console.error('Error getting users:', error); console.error('Error getting users:', error);
res.json([]); res.status(500).json({ error: 'Erro ao buscar usuários.' });
} }
}; };
@@ -99,23 +68,21 @@ export const updateUserRole = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { role } = req.body; const { role } = req.body;
const organizationId = req.headers['x-organization-id'] as string;
if (!['guest', 'user', 'admin'].includes(role)) { if (!organizationId) {
return res.status(400).json({ error: 'Role inválido.' }); return res.status(400).json({ error: 'Organização não selecionada.' });
} }
const { data, error } = await supabase const member = await userService.updateUserRole(id, organizationId, role);
.from('user_organizations') if (!member) {
.update({ role }) return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
.eq('id', id) }
.select()
.single();
if (error && error.code !== '42P01') throw error; res.json(member);
res.json(data || { message: 'Role atualizado' }); } catch (error) {
} catch (error: any) {
console.error('Error updating role:', error); console.error('Error updating role:', error);
res.json({ message: 'Role atualizado' }); res.status(500).json({ error: 'Erro ao alterar role.' });
} }
}; };
@@ -123,100 +90,72 @@ export const toggleBanUser = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { isBanned } = req.body; const { isBanned } = req.body;
const organizationId = req.headers['x-organization-id'] as string;
const { data, error } = await supabase if (!organizationId) {
.from('user_organizations') return res.status(400).json({ error: 'Organização não selecionada.' });
.update({ is_banned: isBanned }) }
.eq('id', id)
.select()
.single();
if (error && error.code !== '42P01') throw error; const member = await userService.toggleBanUser(id, organizationId, isBanned);
res.json(data || { message: 'Ban atualizado' }); if (!member) {
} catch (error: any) { return res.status(404).json({ error: 'Usuário não encontrado nesta organização.' });
}
res.json(member);
} catch (error) {
console.error('Error toggling ban:', error); console.error('Error toggling ban:', error);
res.json({ message: 'Ban atualizado' }); res.status(500).json({ error: 'Erro ao alterar status de banimento.' });
} }
}; };
export const heartbeat = async (req: AuthRequest, res: Response) => { export const heartbeat = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.appUser) { if (!req.appUser) {
return res.status(200).send(); return res.status(401).json({ error: 'Não autenticado.' });
} }
await userService.heartbeat(req.appUser.id);
try {
await supabase
.from('users')
.update({ last_seen_at: new Date().toISOString() })
.eq('id', req.appUser.id);
} catch (e) { /* ignore */ }
res.status(200).send(); res.status(200).send();
} catch (error) { } catch (error) {
console.error('Heartbeat error:', error); console.error('Heartbeat error:', error);
res.status(200).send(); res.status(500).send();
} }
}; };
export const getActiveUsers = async (req: AuthRequest, res: Response) => { export const getActiveUsers = async (req: AuthRequest, res: Response) => {
try { try {
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000).toISOString(); const organizationId = req.headers['x-organization-id'] as string;
const currentUserId = req.appUser?.id;
const { data, error } = await supabase if (!organizationId) {
.from('users') return res.status(400).json([]);
.select('id, email, name, last_seen_at') }
.gte('last_seen_at', twoMinutesAgo);
if (error && error.code !== '42P01') throw error; const activeUsers = await userService.getActiveUsers(organizationId, currentUserId);
res.json(data || []); res.json(activeUsers);
} catch (error: any) { } catch (error) {
console.error('Error getting active users:', error); console.error('Error getting active users:', error);
res.json([]); res.status(500).json([]);
} }
}; };
export const deleteUser = async (req: Request, res: Response) => { export const deleteUser = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const organizationId = req.headers['x-organization-id'] as string;
const { error } = await supabase if (!organizationId) {
.from('user_organizations') return res.status(400).json({ error: 'Organização não selecionada.' });
.delete() }
.eq('id', id);
const success = await userService.deleteUserFromOrg(id, organizationId);
if (!success) {
return res.status(404).json({ error: 'Membro não encontrado.' });
}
if (error && error.code !== '42P01') throw error;
res.json({ message: 'Membro removido com sucesso.' }); res.json({ message: 'Membro removido com sucesso.' });
} catch (error: any) { } catch (error) {
console.error('Error deleting user:', error); console.error('Error deleting user:', error);
res.json({ message: 'Membro removido com sucesso.' }); res.status(500).json({ error: 'Erro ao remover membro.' });
} }
}; };
export const login = async (req: Request, res: Response) => {
try {
const { password } = req.body;
// In a real app, this should check against a hashed DB value or Logto.
// For now, moving the hardcoded password to the backend is safer than frontend.
const adminPassword = process.env.ADMIN_PASSWORD || '@@Gi05Br;;';
if (password === adminPassword) {
// Success - return a token
return res.json({
success: true,
token: 'gpi-admin-token',
user: {
id: 'admin-user',
role: 'admin',
name: 'Administrador (DEV)',
email: 'admtracksteel@gmail.com'
}
});
}
return res.status(401).json({ success: false, error: 'Senha incorreta.' });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ success: false, error: 'Erro interno.' });
}
};
+33 -53
View File
@@ -1,77 +1,57 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { supabase } from '../config/supabase.js'; import * as yieldStudyService from '../services/yieldStudyService.js';
import { toCamelCase, toSnakeCase } from '../utils/caseMapper.js';
const sanitizeStudyPayload = (body: any) => {
const payload = { ...body };
if (payload.dataSheetId && typeof payload.dataSheetId === 'object') {
payload.dataSheetId = payload.dataSheetId.id || payload.dataSheetId._id || payload.dataSheetId;
}
if (payload.data_sheet_id && typeof payload.data_sheet_id === 'object') {
payload.data_sheet_id = payload.data_sheet_id.id || payload.data_sheet_id._id || payload.data_sheet_id;
}
delete payload.id;
delete payload._id;
delete payload.createdAt;
delete payload.created_at;
delete payload.updatedAt;
delete payload.updated_at;
delete payload.calculatedEpu;
delete payload.calculated_epu;
return toSnakeCase(payload);
};
export const getAllStudies = async (req: Request, res: Response) => { export const getAllStudies = async (req: Request, res: Response) => {
try { try {
const { data, error } = await supabase.from('yield_studies').select('*'); const organizationId = req.appUser?.organizationId;
if (error && error.code !== '42P01') throw error; const studies = await yieldStudyService.getAllStudies(organizationId);
res.json(toCamelCase(data || [])); res.json(studies);
} catch (error: unknown) { } catch (error: unknown) {
res.json([]); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
export const createStudy = async (req: Request, res: Response) => { export const createStudy = async (req: Request, res: Response) => {
try { try {
const payload = sanitizeStudyPayload({ ...req.body, organization_id: req.appUser?.organizationId }); const organizationId = req.appUser?.organizationId;
const { data, error } = await supabase const study = await yieldStudyService.createStudy({ ...req.body, organizationId });
.from('yield_studies') res.status(201).json(study);
.insert(payload)
.select()
.single();
if (error) throw error;
res.status(201).json(toCamelCase(data));
} catch (error: unknown) { } catch (error: unknown) {
console.error('Error creating study:', error); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(400).json({ error: (error as any).message }); res.status(500).json({ error: message });
} }
}; };
export const updateStudy = async (req: Request, res: Response) => { export const updateStudy = async (req: Request, res: Response) => {
try { try {
const payload = sanitizeStudyPayload(req.body); const id = req.params.id as string;
const { data, error } = await supabase const organizationId = req.appUser?.organizationId;
.from('yield_studies') const study = await yieldStudyService.updateStudy(id, req.body, organizationId);
.update(payload) if (study) {
.eq('id', req.params.id) res.json(study);
.select() } else {
.single(); res.status(404).json({ error: 'Study not found' });
if (error) throw error; }
res.json(toCamelCase(data));
} catch (error: unknown) { } catch (error: unknown) {
console.error('Error updating study:', error); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(400).json({ error: (error as any).message }); res.status(500).json({ error: message });
} }
}; };
export const deleteStudy = async (req: Request, res: Response) => { export const deleteStudy = async (req: Request, res: Response) => {
try { try {
await supabase.from('yield_studies').delete().eq('id', req.params.id); const id = req.params.id as string;
res.status(204).send(); const organizationId = req.appUser?.organizationId;
const success = await yieldStudyService.deleteStudy(id, organizationId);
if (success) {
res.status(204).send();
} else {
res.status(404).json({ error: 'Study not found' });
}
} catch (error: unknown) { } catch (error: unknown) {
res.status(500).json({ error: (error as any).message }); const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
} }
}; };
+17 -20
View File
@@ -1,36 +1,33 @@
import dotenv from 'dotenv';
dotenv.config();
import app from './app.js'; import app from './app.js';
import dotenv from 'dotenv';
import { connectDB } from './config/database.js'; import { connectDB } from './config/database.js';
import { notificationService } from './services/notificationService.js'; import { notificationService } from './services/notificationService.js';
dotenv.config();
const startServer = async () => { const startServer = async () => {
try { try {
console.log('🔄 Connecting to database...');
await connectDB(); await connectDB();
const PORT = parseInt(process.env.PORT || '3000', 10); const PORT = process.env.PORT || 3000;
app.listen(Number(PORT), '0.0.0.0', async () => {
const server = app.listen(PORT, '0.0.0.0', () => { console.log(`🚀 Server running on port ${PORT} (0.0.0.0)`);
console.log(`🚀 Server running on port ${PORT}`);
console.log('✅ Conectado ao Supabase (GPI schema)'); // Agendar verificação de vencimento de estoque (a cada 24 horas)
}); console.log('📅 Scheduling stock expiration check...');
setInterval(() => {
notificationService.checkStockExpirations();
}, 24 * 60 * 60 * 1000);
server.timeout = 60000; // Executar uma vez no início para garantir (opcional, bom para dev)
// notificationService.checkStockExpirations();
});
} catch (error) { } catch (error) {
console.error('Failed to start server:', error); console.error('Failed to start server:', error);
process.exit(1);
} }
}; };
startServer(); startServer();
process.on('uncaughtException', (err) => { // Force keep-alive to debug why it exits
console.error('Uncaught Exception:', err); setInterval(() => { }, 1000);
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
});
-157
View File
@@ -1,157 +0,0 @@
import { supabase, findOneGpi, queryGpi, insertGpi, updateGpi, deleteGpi } from '../config/supabase.js';
/**
* Mongoose Compatibility Layer for Supabase (v2025)
* Translates Mongoose-style calls to Supabase PostgREST queries.
* Automatically handles camelCase (JS) to snake_case (DB) mapping for core fields.
*/
function createModel(tableName: string) {
const mapToDb = (data: any) => {
const mapped: any = { ...data };
if (mapped.organizationId) { mapped.organization_id = mapped.organizationId; delete mapped.organizationId; }
if (mapped.projectId) { mapped.project_id = mapped.projectId; delete mapped.projectId; }
if (mapped.createdBy) { mapped.created_by = mapped.createdBy; delete mapped.createdBy; }
if (mapped.createdAt) { mapped.created_at = mapped.createdAt; delete mapped.createdAt; }
if (mapped.updatedAt) { mapped.updated_at = mapped.updatedAt; delete mapped.updatedAt; }
return mapped;
};
const mapFromDb = (data: any) => {
if (!data) return data;
if (Array.isArray(data)) return data.map(mapFromDb);
const mapped: any = { ...data, id: data.id || data._id };
if (mapped.organization_id) mapped.organizationId = mapped.organization_id;
if (mapped.project_id) mapped.projectId = mapped.project_id;
if (mapped.created_by) mapped.createdBy = mapped.created_by;
return mapped;
};
return {
find: function(query: any = {}) {
const dbQuery = mapToDb(query);
const promise = (async () => {
const { data, error } = await queryGpi(tableName, { filter: dbQuery });
if (error) throw error;
return mapFromDb(data || []);
})();
// Mock methods for chainability
(promise as any).sort = () => promise;
(promise as any).populate = () => promise;
(promise as any).lean = () => promise;
(promise as any).limit = () => promise;
(promise as any).select = () => promise;
return promise;
},
findOne: async (query: any) => {
const data = await findOneGpi(tableName, mapToDb(query));
return mapFromDb(data);
},
findById: async (id: string) => {
const data = await findOneGpi(tableName, { id });
return mapFromDb(data);
},
create: async (data: any) => {
const dbData = mapToDb(data);
const result = await insertGpi(tableName, dbData);
return mapFromDb(result.data?.[0] || result.data);
},
insertMany: async (dataArray: any[]) => {
const dbDataArray = dataArray.map(mapToDb);
const { data, error } = await supabase.from(tableName).insert(dbDataArray).select();
if (error) throw error;
return mapFromDb(data);
},
deleteMany: async (query: any) => {
const dbQuery = mapToDb(query);
let q = supabase.from(tableName).delete();
Object.keys(dbQuery).forEach(key => {
q = q.eq(key, dbQuery[key]);
});
const { error } = await q;
if (error) throw error;
return { deletedCount: 0 }; // Supabase doesn't return count easily here
},
countDocuments: async (query: any = {}) => {
const dbQuery = mapToDb(query);
let q = supabase.from(tableName).select('*', { count: 'exact', head: true });
Object.keys(dbQuery).forEach(key => {
q = q.eq(key, dbQuery[key]);
});
const { count, error } = await q;
if (error) throw error;
return count || 0;
},
findOneAndUpdate: async (query: any, update: any) => {
const existing = await findOneGpi(tableName, mapToDb(query));
if (!existing) return null;
const result = await updateGpi(tableName, existing.id, mapToDb(update));
return mapFromDb(result.data?.[0]);
},
findByIdAndUpdate: async (id: string, update: any) => {
const result = await updateGpi(tableName, id, mapToDb(update));
return mapFromDb(result.data?.[0]);
},
findByIdAndDelete: async (id: string) => {
await deleteGpi(tableName, id);
return { id };
},
findOneAndDelete: async (query: any) => {
const dbQuery = mapToDb(query);
const existing = await findOneGpi(tableName, dbQuery);
if (!existing) return null;
await deleteGpi(tableName, existing.id);
return mapFromDb(existing);
},
updateMany: async (query: any, update: any) => {
const dbQuery = mapToDb(query);
const dbUpdate = mapToDb(update.$set || update);
let q = supabase.from(tableName).update(dbUpdate);
Object.keys(dbQuery).forEach(key => {
q = q.eq(key, dbQuery[key]);
});
const { error } = await q;
if (error) throw error;
return { acknowledged: true, modifiedCount: 0 };
},
deleteOne: async (query: any) => {
const existing = await findOneGpi(tableName, mapToDb(query));
if (!existing) return null;
await deleteGpi(tableName, existing.id);
return existing;
},
aggregate: (pipeline: any[]) => ({ toArray: async () => [] }),
// For "new Model()" usage
new: function(data: any) {
const instance = { ...data };
(instance as any).save = async () => {
return await insertGpi(tableName, mapToDb(instance));
};
(instance as any).toObject = () => instance;
return instance;
}
};
}
export const Project = createModel('projects');
export const Part = createModel('parts');
export const PaintingScheme = createModel('painting_schemes');
export const ApplicationRecord = createModel('application_records');
export const Inspection = createModel('inspections');
export const User = createModel('users');
export const Organization = createModel('organizations');
export const OrganizationMember = createModel('user_organizations');
export const StockItem = createModel('stock_items');
export const StockMovement = createModel('stock_movements');
export const StockAuditLog = createModel('stock_audit_logs');
export const Instrument = createModel('instruments');
export const TechnicalDataSheet = createModel('technical_data_sheets');
export const SystemSettings = createModel('system_settings');
export const Notification = createModel('notifications');
export const Message = createModel('messages');
export const GeometryType = createModel('geometry_types');
export const YieldStudy = createModel('yield_studies');
export const StoredFile = createModel('stored_files');
export { queryGpi, findOneGpi, insertGpi, updateGpi, deleteGpi };
-77
View File
@@ -1,77 +0,0 @@
import { supabase, findOneGpi, queryGpi, insertGpi, updateGpi, deleteGpi } from '../config/supabase.js';
export { supabase, findOneGpi, queryGpi, insertGpi, updateGpi, deleteGpi };
export async function getModel(tableName: string) {
return {
find: async (query: any = {}) => {
const { data, error } = await queryGpi(tableName, query);
if (error) throw error;
return data || [];
},
findOne: async (query: any) => {
return await findOneGpi(tableName, query);
},
findById: async (id: string) => {
return await findOneGpi(tableName, { id });
},
create: async (data: any) => {
const result = await insertGpi(tableName, data);
return result.data?.[0];
},
findOneAndUpdate: async (query: any, data: any) => {
const existing = await findOneGpi(tableName, query);
if (!existing) return null;
const result = await updateGpi(tableName, existing.id, data);
return result.data?.[0];
},
findByIdAndUpdate: async (id: string, data: any) => {
const result = await updateGpi(tableName, id, data);
return result.data?.[0];
},
findOneAndDelete: async (query: any) => {
const existing = await findOneGpi(tableName, query);
if (!existing) return null;
await deleteGpi(tableName, existing.id);
return existing;
},
countDocuments: async (query: any = {}) => {
let q = supabase.from(tableName).select('*', { count: 'exact', head: true });
if (query) {
Object.keys(query).forEach(key => {
q = q.eq(key, query[key]);
});
}
const { count, error } = await q;
if (error) throw error;
return count || 0;
}
};
}
export function getModelById(tableName: string, idField: string = 'id') {
return {
find: async (query: any = {}) => {
const { data, error } = await queryGpi(tableName, { filter: query });
if (error) throw error;
return data || [];
},
findOne: async (query: any) => {
return await findOneGpi(tableName, query);
},
create: async (data: any) => {
const result = await insertGpi(tableName, data);
return result.data?.[0];
},
findByIdAndUpdate: async (id: string, data: any) => {
const result = await updateGpi(tableName, id, data);
return result.data?.[0];
},
findByIdAndDelete: async (id: string) => {
await deleteGpi(tableName, id);
return { id };
}
};
}
+26
View File
@@ -0,0 +1,26 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_key_change_in_prod';
export const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
// Se não houver token autêntico JWT, prossegue limpo
return next();
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET) as any;
// Injeta o externalId no header para que o extractUser (roleMiddleware)
// continue seu trabalho de carregar o usuário do banco instanciado e popular req.appUser
req.headers['x-auth-user-id'] = decoded.externalId;
next();
} catch (error) {
console.error('Auth Middleware Error:', error);
res.status(401).json({ error: 'Token inválido ou expirado' });
}
};
-87
View File
@@ -1,87 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { authenticateRequest } from './logtoAuth.js';
export interface IAppUser {
id: string;
logtoId: string;
email: string;
name: string;
role: string;
organizationId?: string;
organizationRole?: string;
}
declare module 'express-serve-static-core' {
interface Request {
appUser?: IAppUser;
}
}
const DEFAULT_ORG_ID = 'e47e6210-4879-4e5b-bf21-9285d2713123';
const GUEST_USER: IAppUser = {
id: '00000000-0000-0000-0000-000000000000',
logtoId: 'guest',
email: 'guest@gpi.app',
name: 'Guest User',
role: 'guest',
organizationId: DEFAULT_ORG_ID,
organizationRole: 'guest'
};
export const extractUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const authenticatedUser = await authenticateRequest(req);
if (authenticatedUser) {
req.appUser = {
...authenticatedUser,
organizationId: req.headers['x-organization-id'] as string || DEFAULT_ORG_ID,
organizationRole: authenticatedUser.role
};
} else {
req.appUser = {
...GUEST_USER,
organizationId: req.headers['x-organization-id'] as string || DEFAULT_ORG_ID,
};
}
} catch {
req.appUser = {
...GUEST_USER,
organizationId: req.headers['x-organization-id'] as string || DEFAULT_ORG_ID,
};
}
next();
};
export const requireRole = (allowedRoles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.appUser || !allowedRoles.includes(req.appUser.role)) {
return res.status(403).json({ error: 'Acesso negado. Permissão insuficiente.' });
}
next();
};
};
export const requireAdmin = requireRole(['admin']);
export const requireUser = requireRole(['user', 'admin']);
export const canEdit = (req: Request, res: Response, next: NextFunction) => {
if (!req.appUser || req.appUser.role === 'guest') {
return res.status(403).json({ error: 'Convidados não podem editar. Faça login para ter acesso completo.' });
}
next();
};
export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => {
const isDev = req.appUser && (
req.appUser.email === 'admtracksteel@gmail.com' ||
req.appUser.role === 'admin' ||
req.appUser.id === 'admin-user' ||
req.appUser.id === 'c0eebc99-9c0b-4ef8-bb6d-6bb9bd380a10' ||
req.appUser.logtoId === 'admin'
);
if (!isDev) {
return res.status(403).json({ error: 'Acesso restrito ao desenvolvedor.' });
}
next();
};
-130
View File
@@ -1,130 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { supabase, findOneGpi } from '../config/supabase.js';
import { IAppUser } from './authMiddleware.js';
const LOGTO_URL = process.env.LOGTO_URL || 'https://logto-admin-bzlued1boxl3t8ewsyn99an9.187.77.227.172.sslip.io';
const APP_ID = process.env.LOGTO_APP_ID || 'gpi-app-final';
const jwks = createRemoteJWKSet(new URL(`${LOGTO_URL}/oidc/jwks`));
export type AppUser = IAppUser;
export async function authenticateRequest(req: Request): Promise<IAppUser | null> {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
console.log('[Auth] Missing or invalid authorization header:', authHeader);
return null;
}
const token = authHeader.substring(7);
if (token === 'gpi-admin-token') {
return {
id: 'c0eebc99-9c0b-4ef8-bb6d-6bb9bd380a10',
logtoId: 'admin',
email: 'admtracksteel@gmail.com',
name: 'Administrador (Dev)',
role: 'admin'
};
}
console.log('[Auth] Verifying real token starting with:', token.substring(0, 15) + '...');
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: `${LOGTO_URL}/oidc`,
audience: APP_ID
});
const logtoId = payload.sub as string;
// Primeiro tenta pelo Logto ID
let user = await findOneGpi('users', { logto_id: logtoId });
// Se não encontrar, tenta pelo email (se houver no payload do token)
if (!user && payload.email) {
const email = payload.email as string;
user = await findOneGpi('users', { email });
if (user) {
// Vincula o Logto ID ao usuário existente
await supabase
.from('users')
.update({ logto_id: logtoId })
.eq('id', user.id);
user.logto_id = logtoId;
console.log(`[Auth] Usuário ${email} vinculado ao Logto ID ${logtoId}`);
}
}
// Auto-registro se não encontrar
if (!user) {
console.log(`[Auth] Usuário Logto ${logtoId} sem registro no GPI. Criando...`);
const email = (payload.email as string) || '';
const name = (payload.name as string) || (payload.username as string) || email.split('@')[0];
const { data: newUser, error: createError } = await supabase
.from('users')
.insert({
email,
name,
logto_id: logtoId,
role: 'user'
})
.select()
.single();
if (createError) {
console.error('[Auth] Erro ao auto-registrar usuário:', createError);
return null;
}
user = newUser;
console.log(`[Auth] Novo usuário auto-registrado: ${email}`);
}
return {
id: user.id || user._id,
logtoId: user.logto_id,
email: user.email,
name: user.name,
role: user.role
};
} catch (error) {
console.error('[Auth] Erro ao verificar token:', error);
return null;
}
}
export function requireAuth() {
return async (req: Request, res: Response, next: NextFunction) => {
const user = await authenticateRequest(req);
if (!user) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.appUser = user;
next();
};
}
export function requireRole(roles: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = await authenticateRequest(req);
if (!user) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!roles.includes(user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
req.appUser = user;
next();
};
}
+137
View File
@@ -0,0 +1,137 @@
import { Request, Response, NextFunction } from 'express';
import { query } from '../config/database.js';
export type UserRole = 'guest' | 'user' | 'admin';
export type OrgRole = 'guest' | 'user' | 'admin';
export interface IAppUser {
id: string;
externalId: string;
email: string;
name: string;
role: UserRole;
isBanned: boolean;
organizationId?: string;
organizationRole?: OrgRole;
organizationBanned?: boolean;
}
export const extractUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const externalId = req.headers['x-auth-user-id'] as string;
const organizationId = req.headers['x-organization-id'] as string;
if (!externalId) {
return next();
}
// Buscar usuário (compatível com clerk_id ou logto_id salvos como external_id no futuro,
// ou usando as colunas atuais clerk_id/logto_id)
const userRes = await query(
'SELECT * FROM gpi.users WHERE clerk_id = $1 OR logto_id = $1',
[externalId]
);
const user = userRes.rows[0];
if (user) {
if (user.is_banned) {
return res.status(403).json({ error: 'Conta bloqueada. Entre em contato com o administrador.' });
}
const appUser: IAppUser = {
id: user.id,
externalId: user.clerk_id || user.logto_id || externalId,
email: user.email,
name: user.name,
role: user.role,
isBanned: user.is_banned,
organizationId: organizationId
};
if (organizationId) {
// Verificar organização
const orgRes = await query(
'SELECT * FROM gpi.organizations WHERE id = $1 OR clerk_id = $1 OR logto_id = $1',
[organizationId]
);
const org = orgRes.rows[0];
if (org) {
if (org.is_banned) {
return res.status(403).json({ error: 'Acesso bloqueado: Esta organização está suspensa.' });
}
// Buscar papel na organização
const memberRes = await query(
'SELECT role, is_banned FROM gpi.user_organizations WHERE user_id = $1 AND organization_id = $2',
[user.id, org.id]
);
const member = memberRes.rows[0];
if (member) {
if (member.is_banned) {
return res.status(403).json({ error: 'Acesso bloqueado nesta organização.' });
}
appUser.organizationRole = member.role;
appUser.role = member.role; // Override global role
} else {
appUser.organizationRole = 'guest';
appUser.role = 'guest';
}
}
}
req.appUser = appUser;
}
next();
} catch (error) {
console.error('Error extracting user:', error);
next();
}
};
export const requireRole = (allowedRoles: OrgRole[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.appUser) {
return res.status(401).json({ error: 'Autenticação necessária.' });
}
if (req.appUser.email === 'admtracksteel@gmail.com') {
return next();
}
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
if (!allowedRoles.includes(effectiveRole as OrgRole)) {
return res.status(403).json({ error: 'Acesso negado. Permissões insuficientes.' });
}
next();
};
};
export const requireAdmin = requireRole(['admin']);
export const requireUser = requireRole(['user', 'admin']);
export const canEdit = (req: Request, res: Response, next: NextFunction) => {
if (!req.appUser) {
return res.status(401).json({ error: 'Autenticação necessária.' });
}
const effectiveRole = req.appUser.organizationRole || req.appUser.role;
if (effectiveRole === 'guest') {
return res.status(403).json({ error: 'Convidados não podem editar.' });
}
next();
};
export const requireDeveloper = (req: Request, res: Response, next: NextFunction) => {
if (!req.appUser) {
return res.status(401).json({ error: 'Autenticação necessária.' });
}
if (req.appUser.email !== 'admtracksteel@gmail.com') {
return res.status(403).json({ error: 'Acesso restrito ao desenvolvedor.' });
}
next();
};
+6 -5
View File
@@ -1,14 +1,15 @@
import { Router } from 'express'; import { Router } from 'express';
import * as appRecordController from '../controllers/applicationRecordController.js'; import * as appRecordController from '../controllers/applicationRecordController.js';
import { extractUser, requireUser } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
// Public routes (read-only) // Public routes (read-only)
router.get('/project/:projectId', appRecordController.getApplicationRecordsByProject); router.get('/project/:projectId', extractUser, appRecordController.getApplicationRecordsByProject);
// Modification routes // Protected routes (require user permission)
router.post('/', appRecordController.createApplicationRecord); router.post('/', extractUser, requireUser, appRecordController.createApplicationRecord);
router.put('/:id', appRecordController.updateApplicationRecord); router.put('/:id', extractUser, requireUser, appRecordController.updateApplicationRecord);
router.delete('/:id', appRecordController.deleteApplicationRecord); router.delete('/:id', extractUser, requireUser, appRecordController.deleteApplicationRecord);
export default router; export default router;
+10
View File
@@ -0,0 +1,10 @@
import express from 'express';
import { login, register, getMe } from '../controllers/authController.js';
const router = express.Router();
router.post('/login', login);
router.post('/register', register);
router.get('/me', getMe);
export default router;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { backupService } from '../services/backupService.js'; import { backupService } from '../services/backupService.js';
import { requireRole } from '../middleware/authMiddleware.js'; import { requireRole } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
+28 -8
View File
@@ -3,22 +3,42 @@ import multer from 'multer';
import path from 'path'; import path from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as dataSheetController from '../controllers/dataSheetController.js'; import * as dataSheetController from '../controllers/dataSheetController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
import os from 'os'; import os from 'os';
const router = Router(); const router = Router();
// Configure Multer
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, os.tmpdir()), destination: (req, file, cb) => {
filename: (req, file, cb) => cb(null, `${Date.now()}-${uuidv4()}${path.extname(file.originalname)}`) cb(null, os.tmpdir());
},
filename: (req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${uuidv4()}`;
cb(null, `${uniqueSuffix}${path.extname(file.originalname)}`);
}
}); });
const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } }); const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf' || file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only PDF and image files are allowed'));
}
}
});
// Public routes (read-only)
router.get('/', dataSheetController.getAllDataSheets); router.get('/', dataSheetController.getAllDataSheets);
router.get('/file/:id', dataSheetController.getFile); router.get('/file/:id', dataSheetController.getFile);
router.post('/', upload.single('file'), dataSheetController.createDataSheet);
router.post('/extract', upload.single('file'), dataSheetController.extractData);
router.put('/:id', upload.single('file'), dataSheetController.updateDataSheet);
router.delete('/:id', dataSheetController.deleteDataSheet);
export default router; // Protected routes (require edit permission)
router.post('/', extractUser, requireAdmin, upload.single('file'), dataSheetController.createDataSheet);
router.post('/extract', extractUser, requireAdmin, upload.single('file'), dataSheetController.extractData);
router.put('/:id', extractUser, requireAdmin, upload.single('file'), dataSheetController.updateDataSheet);
router.delete('/:id', extractUser, requireAdmin, dataSheetController.deleteDataSheet);
export default router;
+10 -6
View File
@@ -1,12 +1,16 @@
import { Router } from 'express'; import { Router } from 'express';
import * as geometryTypeController from '../controllers/geometryTypeController.js'; import * as geometryTypeController from '../controllers/geometryTypeController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
router.get('/', geometryTypeController.getAllnames); // Retrieve all types (public read for authenticated users, auto-seeds)
router.post('/restore', geometryTypeController.restoreDefaults); router.get('/', extractUser, geometryTypeController.getAllnames);
router.post('/', geometryTypeController.createType);
router.put('/:id', geometryTypeController.updateType);
router.delete('/:id', geometryTypeController.deleteType);
export default router; // Protected Routes (Edit Only)
router.post('/restore', extractUser, requireAdmin, geometryTypeController.restoreDefaults);
router.post('/', extractUser, requireAdmin, geometryTypeController.createType);
router.put('/:id', extractUser, requireAdmin, geometryTypeController.updateType);
router.delete('/:id', extractUser, requireAdmin, geometryTypeController.deleteType);
export default router;
+8 -7
View File
@@ -1,5 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import * as inspectionController from '../controllers/inspectionController.js'; import * as inspectionController from '../controllers/inspectionController.js';
import { extractUser, requireUser } from '../middleware/roleMiddleware.js';
import multer from 'multer'; import multer from 'multer';
import path from 'path'; import path from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@@ -41,15 +42,15 @@ const upload = multer({
}); });
// Upload route // Upload route
router.post('/upload', upload.single('photo'), inspectionController.uploadPhoto); router.post('/upload', extractUser, requireUser, upload.single('photo'), inspectionController.uploadPhoto);
// Public routes (read-only) // Public routes (read-only)
router.get('/', inspectionController.getAllInspections); router.get('/', extractUser, inspectionController.getAllInspections);
router.get('/project/:projectId', inspectionController.getInspectionsByProject); router.get('/project/:projectId', extractUser, inspectionController.getInspectionsByProject);
// Modification routes // Protected routes (require user permission)
router.post('/', inspectionController.createInspection); router.post('/', extractUser, requireUser, inspectionController.createInspection);
router.put('/:id', inspectionController.updateInspection); router.put('/:id', extractUser, requireUser, inspectionController.updateInspection);
router.delete('/:id', inspectionController.deleteInspection); router.delete('/:id', extractUser, requireUser, inspectionController.deleteInspection);
export default router; export default router;
+6 -5
View File
@@ -1,11 +1,12 @@
import { Router } from 'express'; import { Router } from 'express';
import * as instrumentController from '../controllers/instrumentController.js'; import * as instrumentController from '../controllers/instrumentController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
router.get('/', instrumentController.getInstruments); router.post('/', extractUser, requireAdmin, instrumentController.createInstrument);
router.post('/', instrumentController.createInstrument); router.get('/', extractUser, instrumentController.getInstruments);
router.put('/:id', instrumentController.updateInstrument); router.put('/:id', extractUser, requireAdmin, instrumentController.updateInstrument);
router.delete('/:id', instrumentController.deleteInstrument); router.delete('/:id', extractUser, requireAdmin, instrumentController.deleteInstrument);
export default router; export default router;
+8 -8
View File
@@ -1,16 +1,16 @@
import express from 'express'; import express from 'express';
import { sendMessage, getUnreadMessages, markMessageAsRead, getMyPendingMessages, deleteMessage, archiveMessage, recipientDeleteMessage } from '../controllers/messageController.js'; import { sendMessage, getUnreadMessages, markMessageAsRead, getMyPendingMessages, deleteMessage, archiveMessage, recipientDeleteMessage } from '../controllers/messageController.js';
import { } from '../middleware/authMiddleware.js'; import { extractUser } from '../middleware/roleMiddleware.js';
const router = express.Router(); const router = express.Router();
// All routes require authentication // All routes require authentication
router.post('/', sendMessage); router.post('/', extractUser, sendMessage);
router.get('/unread', getUnreadMessages); router.get('/unread', extractUser, getUnreadMessages);
router.get('/pending', getMyPendingMessages); router.get('/pending', extractUser, getMyPendingMessages);
router.patch('/:id/read', markMessageAsRead); router.patch('/:id/read', extractUser, markMessageAsRead);
router.patch('/:id/archive', archiveMessage); router.patch('/:id/archive', extractUser, archiveMessage);
router.delete('/:id', deleteMessage); // Sender side (if unread) router.delete('/:id', extractUser, deleteMessage); // Sender side (if unread)
router.delete('/:id/recipient', recipientDeleteMessage); // Recipient side router.delete('/:id/recipient', extractUser, recipientDeleteMessage); // Recipient side
export default router; export default router;
+7 -3
View File
@@ -1,12 +1,16 @@
import { Router } from 'express'; import { Router } from 'express';
import * as paintingSchemeController from '../controllers/paintingSchemeController.js'; import * as paintingSchemeController from '../controllers/paintingSchemeController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
// Public routes (read-only)
router.get('/', paintingSchemeController.getAllPaintingSchemes); router.get('/', paintingSchemeController.getAllPaintingSchemes);
router.get('/project/:projectId', paintingSchemeController.getPaintingSchemesByProject); router.get('/project/:projectId', paintingSchemeController.getPaintingSchemesByProject);
router.post('/', paintingSchemeController.createPaintingScheme);
router.put('/:id', paintingSchemeController.updatePaintingScheme); // Protected routes (require admin permission)
router.delete('/:id', paintingSchemeController.deletePaintingScheme); router.post('/', extractUser, requireAdmin, paintingSchemeController.createPaintingScheme);
router.put('/:id', extractUser, requireAdmin, paintingSchemeController.updatePaintingScheme);
router.delete('/:id', extractUser, requireAdmin, paintingSchemeController.deletePaintingScheme);
export default router; export default router;
+7 -6
View File
@@ -1,15 +1,16 @@
import { Router } from 'express'; import { Router } from 'express';
import * as partController from '../controllers/partController.js'; import * as partController from '../controllers/partController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
// Public routes (read-only) // Public routes (read-only)
router.get('/', partController.getAllParts); router.get('/', extractUser, partController.getAllParts);
router.get('/project/:projectId', partController.getPartsByProject); router.get('/project/:projectId', extractUser, partController.getPartsByProject);
// Modification routes // Protected routes (require admin permission)
router.post('/', partController.createPart); router.post('/', extractUser, requireAdmin, partController.createPart);
router.put('/:id', partController.updatePart); router.put('/:id', extractUser, requireAdmin, partController.updatePart);
router.delete('/:id', partController.deletePart); router.delete('/:id', extractUser, requireAdmin, partController.deletePart);
export default router; export default router;
+9 -8
View File
@@ -1,17 +1,18 @@
import { Router } from 'express'; import { Router } from 'express';
import * as projectController from '../controllers/projectController.js'; import * as projectController from '../controllers/projectController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
// Rotas com contexto de usuário (leitura) // Rotas com contexto de usuário (leitura)
router.get('/', projectController.getAllProjects); router.get('/', extractUser, projectController.getAllProjects);
router.get('/dashboard', projectController.getDashboardProjects); router.get('/dashboard', extractUser, projectController.getDashboardProjects);
router.get('/:id', projectController.getProjectById); router.get('/:id', extractUser, projectController.getProjectById);
// Rotas de modificação de projetos // Protected routes (require admin permission)
router.post('/', projectController.createProject); router.post('/', extractUser, requireAdmin, projectController.createProject);
router.put('/:id', projectController.updateProject); router.put('/:id', extractUser, requireAdmin, projectController.updateProject);
router.delete('/:id', projectController.deleteProject); router.delete('/:id', extractUser, requireAdmin, projectController.deleteProject);
router.patch('/:id/archive', projectController.archiveProject); router.patch('/:id/archive', extractUser, requireAdmin, projectController.archiveProject);
export default router; export default router;
+34 -12
View File
@@ -1,18 +1,40 @@
import { Router } from 'express'; import { Router } from 'express';
import * as stockController from '../controllers/stockController.js'; import * as stockController from '../controllers/stockController.js';
import { extractUser, requireAdmin, requireUser } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
router.get('/', stockController.getStockItems); // Retrieve all items
router.get('/:id', stockController.getStockItemById); router.get('/', extractUser, stockController.getStockItems);
router.get('/:id/movements', stockController.getStockMovements);
router.get('/:id/logs', stockController.getStockAuditLogs);
router.post('/', stockController.createStockItem);
router.put('/:id', stockController.updateStockItem);
router.post('/:id/adjust', stockController.adjustStock);
router.post('/:id/consume', stockController.consumeStock);
router.delete('/:id', stockController.deleteStockItem);
router.put('/movements/:id', stockController.updateStockMovement);
router.delete('/movements/:id', stockController.deleteStockMovement);
export default router; // Retrieve Item Details
router.get('/:id', extractUser, stockController.getStockItemById);
// Retrieve movements for a specific item
router.get('/:id/movements', extractUser, stockController.getStockMovements);
// Retrieve logs for a specific item
router.get('/:id/logs', extractUser, stockController.getStockAuditLogs);
// Create (Entry)
router.post('/', extractUser, requireUser, stockController.createStockItem);
// Update Details (No quantity)
router.put('/:id', extractUser, requireAdmin, stockController.updateStockItem);
// Technical Adjustment (Baixa Técnica / Correção)
router.post('/:id/adjust', extractUser, requireAdmin, stockController.adjustStock);
// Consumption (Baixa por Obra)
router.post('/:id/consume', extractUser, requireUser, stockController.consumeStock);
// Delete Stock Item (and its movements)
router.delete('/:id', extractUser, requireAdmin, stockController.deleteStockItem);
// -----------------------------------------------------------
// Movement CRUD
// -----------------------------------------------------------
router.put('/movements/:id', extractUser, requireAdmin, stockController.updateStockMovement);
router.delete('/movements/:id', extractUser, requireAdmin, stockController.deleteStockMovement);
export default router;
+6 -6
View File
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { getSettings, updateSettings, uploadLogo, serveLogo } from '../controllers/systemSettingsController.js'; import { getSettings, updateSettings, uploadLogo, serveLogo } from '../controllers/systemSettingsController.js';
import { requireDeveloper } from '../middleware/authMiddleware.js'; import { extractUser, requireDeveloper } from '../middleware/roleMiddleware.js';
import { uploadLogoDetails } from '../middleware/uploadMiddleware.js'; import { uploadLogoDetails } from '../middleware/uploadMiddleware.js';
const router = express.Router(); const router = express.Router();
@@ -9,10 +9,10 @@ const router = express.Router();
router.get('/', getSettings); router.get('/', getSettings);
// Protected update // Protected update
router.put('/', updateSettings); router.put('/', extractUser, requireDeveloper, updateSettings);
// Protected upload // Protected upload
router.post('/logo', uploadLogoDetails.single('logo'), uploadLogo); router.post('/logo', extractUser, requireDeveloper, uploadLogoDetails.single('logo'), uploadLogo);
// Public access to view logo (noauth needed for login screen etc) // Public access to view logo (noauth needed for login screen etc)
router.get('/logo-image/:filename', (req, res) => { router.get('/logo-image/:filename', (req, res) => {
@@ -23,8 +23,8 @@ router.get('/logo-image/:filename', (req, res) => {
// Global Admin Routes // Global Admin Routes
import { getGlobalUsers, getGlobalOrganizations, toggleOrganizationBan } from '../controllers/systemSettingsController.js'; import { getGlobalUsers, getGlobalOrganizations, toggleOrganizationBan } from '../controllers/systemSettingsController.js';
router.get('/users', getGlobalUsers); router.get('/users', extractUser, requireDeveloper, getGlobalUsers);
router.get('/organizations', getGlobalOrganizations); router.get('/organizations', extractUser, requireDeveloper, getGlobalOrganizations);
router.post('/organizations/ban', toggleOrganizationBan); router.post('/organizations/ban', extractUser, requireDeveloper, toggleOrganizationBan);
export default router; export default router;
+14 -13
View File
@@ -1,22 +1,23 @@
import express from 'express'; import express from 'express';
import { getCurrentUser, getAllUsers, updateUserRole, toggleBanUser, heartbeat, getActiveUsers, deleteUser, login } from '../controllers/userController.js'; import { syncUser, getCurrentUser, getAllUsers, updateUserRole, toggleBanUser, heartbeat, getActiveUsers, deleteUser } from '../controllers/userController.js';
import { requireAdmin } from '../middleware/authMiddleware.js'; import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = express.Router(); const router = express.Router();
// Public routes (no auth required originally, but now all API has extractUser) // Sync user from Auth (public - called on login)
// Only users should see all users router.post('/sync', syncUser);
router.get('/', getAllUsers);
router.get('/me', getCurrentUser); // Get current user (requires extractUser middleware)
router.post('/login', login); router.get('/me', extractUser, getCurrentUser);
// Heartbeat & Presence // Heartbeat & Presence
router.post('/heartbeat', heartbeat); router.post('/heartbeat', extractUser, heartbeat);
router.get('/active', getActiveUsers); router.get('/active', extractUser, getActiveUsers);
// Admin routes // Admin-only routes
router.patch('/:id/role', requireAdmin, updateUserRole); router.get('/', extractUser, requireAdmin, getAllUsers);
router.patch('/:id/ban', requireAdmin, toggleBanUser); router.patch('/:id/role', extractUser, requireAdmin, updateUserRole);
router.delete('/:id', requireAdmin, deleteUser); router.patch('/:id/ban', extractUser, requireAdmin, toggleBanUser);
router.delete('/:id', extractUser, requireAdmin, deleteUser);
export default router; export default router;
+9 -5
View File
@@ -1,11 +1,15 @@
import { Router } from 'express'; import { Router } from 'express';
import * as yieldStudyController from '../controllers/yieldStudyController.js'; import * as yieldStudyController from '../controllers/yieldStudyController.js';
import { extractUser, requireAdmin } from '../middleware/roleMiddleware.js';
const router = Router(); const router = Router();
router.get('/', yieldStudyController.getAllStudies); // Public routes (read-only)
router.post('/', yieldStudyController.createStudy); router.get('/', extractUser, yieldStudyController.getAllStudies);
router.put('/:id', yieldStudyController.updateStudy);
router.delete('/:id', yieldStudyController.deleteStudy);
export default router; // Protected routes (require admin permission)
router.post('/', extractUser, requireAdmin, yieldStudyController.createStudy);
router.put('/:id', extractUser, requireAdmin, yieldStudyController.updateStudy);
router.delete('/:id', extractUser, requireAdmin, yieldStudyController.deleteStudy);
export default router;
+326
View File
@@ -0,0 +1,326 @@
-- Full Database Schema for GPI (PostgreSQL)
CREATE SCHEMA IF NOT EXISTS gpi;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 1. Organizations
CREATE TABLE IF NOT EXISTS gpi.organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
clerk_id TEXT UNIQUE, -- Legacy
logto_id TEXT UNIQUE,
name TEXT NOT NULL,
is_banned BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 2. Users
CREATE TABLE IF NOT EXISTS gpi.users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
clerk_id TEXT UNIQUE, -- Legacy
logto_id TEXT UNIQUE,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'guest',
is_banned BOOLEAN DEFAULT FALSE,
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 3. many-to-many user_organizations
CREATE TABLE IF NOT EXISTS gpi.user_organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES gpi.users(id) ON DELETE CASCADE,
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
role TEXT CHECK (role IN ('guest', 'user', 'admin')) DEFAULT 'guest',
is_banned BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE (user_id, organization_id)
);
-- 4. Technical Data Sheets (Fichas Técnicas)
CREATE TABLE IF NOT EXISTS gpi.technical_data_sheets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
manufacturer TEXT,
type TEXT,
file_url TEXT,
solids_volume DECIMAL,
density DECIMAL,
mixing_ratio TEXT,
yield_theoretical DECIMAL,
wft_min DECIMAL,
wft_max DECIMAL,
dft_min DECIMAL,
dft_max DECIMAL,
reducer TEXT,
yield_factor DECIMAL DEFAULT 1.0,
min_stock DECIMAL DEFAULT 0,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 5. Projects
CREATE TABLE IF NOT EXISTS gpi.projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client TEXT NOT NULL,
start_date DATE,
end_date DATE,
technician TEXT,
environment TEXT,
weight_kg DECIMAL,
status TEXT DEFAULT 'active',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 6. Parts
CREATE TABLE IF NOT EXISTS gpi.parts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
description TEXT NOT NULL,
dimensions TEXT,
weight DECIMAL,
type TEXT,
area DECIMAL,
complexity INTEGER,
quantity INTEGER DEFAULT 1,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 7. Painting Schemes
CREATE TABLE IF NOT EXISTS gpi.painting_schemes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT,
coat TEXT, -- Primer, Intermediate, Finish
solids_volume DECIMAL,
yield_theoretical DECIMAL,
eps_min DECIMAL,
eps_max DECIMAL,
dilution DECIMAL,
manufacturer TEXT,
color TEXT,
notes TEXT,
paint_id UUID REFERENCES gpi.technical_data_sheets(id),
thinner_id UUID REFERENCES gpi.technical_data_sheets(id),
color_hex TEXT,
thinner_symbol TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 8. Application Records (Lotes de Pintura)
CREATE TABLE IF NOT EXISTS gpi.application_records (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
coat_stage TEXT,
piece_description TEXT,
date DATE,
operator TEXT,
real_weight DECIMAL,
volume_used DECIMAL,
area_painted DECIMAL,
wet_thickness_avg DECIMAL,
dry_thickness_calc DECIMAL,
real_yield DECIMAL,
method TEXT,
diluent_used DECIMAL,
notes TEXT,
items JSONB DEFAULT '[]', -- List of {partId, quantity}
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 9. Instruments
CREATE TABLE IF NOT EXISTS gpi.instruments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
serial_number TEXT,
type TEXT,
last_calibration DATE,
calibration_due DATE,
status TEXT DEFAULT 'active',
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 10. Stock Items
CREATE TABLE IF NOT EXISTS gpi.stock_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
data_sheet_id UUID REFERENCES gpi.technical_data_sheets(id),
batch_number TEXT NOT NULL,
manufacturing_date DATE,
expiration_date DATE,
initial_quantity DECIMAL NOT NULL,
current_quantity DECIMAL NOT NULL,
unit TEXT DEFAULT 'L',
location TEXT,
status TEXT DEFAULT 'active',
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 11. Inspections
CREATE TABLE IF NOT EXISTS gpi.inspections (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID REFERENCES gpi.projects(id) ON DELETE CASCADE,
application_record_id UUID REFERENCES gpi.application_records(id),
stock_item_id UUID REFERENCES gpi.stock_items(id),
instrument_id UUID REFERENCES gpi.instruments(id),
type TEXT CHECK (type IN ('painting', 'surface_treatment')),
date DATE,
inspector TEXT,
part_temperature DECIMAL,
weight_kg DECIMAL,
appearance TEXT, -- approved, rejected, notes
defects TEXT,
photos TEXT[] DEFAULT '{}',
piece_description TEXT,
eps_points DECIMAL[] DEFAULT '{}',
adhesion_test TEXT,
batch TEXT,
treatment_executor TEXT,
treatment_type TEXT,
cleaning_degree TEXT,
roughness_readings DECIMAL[] DEFAULT '{}',
flash_rust TEXT,
temperature DECIMAL,
relative_humidity DECIMAL,
period TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 12. Stock Movements
CREATE TABLE IF NOT EXISTS gpi.stock_movements (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
stock_item_id UUID REFERENCES gpi.stock_items(id) ON DELETE CASCADE,
type TEXT CHECK (type IN ('in', 'out', 'adjust')),
quantity DECIMAL NOT NULL,
reason TEXT,
project_id UUID REFERENCES gpi.projects(id),
user_id TEXT, -- external_id
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 13. System Settings
CREATE TABLE IF NOT EXISTS gpi.system_settings (
settings_id TEXT PRIMARY KEY DEFAULT 'global',
app_name TEXT DEFAULT 'GPI',
app_subtitle TEXT DEFAULT 'Gestão de Pintura Industrial',
app_logo_url TEXT,
updated_by TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 14. Notifications
CREATE TABLE IF NOT EXISTS gpi.notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
recipient_id UUID REFERENCES gpi.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
message TEXT NOT NULL,
type TEXT CHECK (type IN ('info', 'warning', 'error', 'success')) DEFAULT 'info',
is_read BOOLEAN DEFAULT FALSE,
is_archived BOOLEAN DEFAULT FALSE,
archived_by UUID[] DEFAULT '{}',
deleted_by UUID[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 15. Yield Studies
CREATE TABLE IF NOT EXISTS gpi.yield_studies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
data_sheet_id UUID REFERENCES gpi.technical_data_sheets(id),
name TEXT NOT NULL,
target_dft DECIMAL NOT NULL,
dilution_percent DECIMAL DEFAULT 0,
categories JSONB DEFAULT '[]',
total_weight DECIMAL,
estimated_paint_volume DECIMAL,
estimated_reducer_volume DECIMAL,
estimated_paint_volume_by_area DECIMAL,
estimated_reducer_volume_by_area DECIMAL,
average_complexity DECIMAL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 16. Stock Audit Logs
CREATE TABLE IF NOT EXISTS gpi.stock_audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
stock_item_id UUID REFERENCES gpi.stock_items(id) ON DELETE CASCADE,
movement_id UUID,
movement_number INTEGER,
user_id TEXT, -- external_id
user_name TEXT,
action TEXT NOT NULL,
details TEXT,
old_values JSONB,
new_values JSONB,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 17. Messages
CREATE TABLE IF NOT EXISTS gpi.messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
from_user_id TEXT NOT NULL, -- external_id
to_user_id TEXT NOT NULL, -- external_id
message TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
read_at TIMESTAMP WITH TIME ZONE,
is_archived BOOLEAN DEFAULT FALSE,
is_deleted_by_recipient BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 18. Geometry Types
CREATE TABLE IF NOT EXISTS gpi.geometry_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
efficiency_loss DECIMAL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE (organization_id, name)
);
-- 19. Stored Files
CREATE TABLE IF NOT EXISTS gpi.stored_files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES gpi.organizations(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT,
mimetype TEXT,
size INTEGER,
path TEXT,
category TEXT,
reference_id TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_projects_org ON gpi.projects(organization_id);
CREATE INDEX IF NOT EXISTS idx_parts_project ON gpi.parts(project_id);
CREATE INDEX IF NOT EXISTS idx_schemes_project ON gpi.painting_schemes(project_id);
CREATE INDEX IF NOT EXISTS idx_records_project ON gpi.application_records(project_id);
CREATE INDEX IF NOT EXISTS idx_inspections_project ON gpi.inspections(project_id);
CREATE INDEX IF NOT EXISTS idx_stock_org ON gpi.stock_items(organization_id);
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON gpi.notifications(recipient_id);
CREATE INDEX IF NOT EXISTS idx_messages_to ON gpi.messages(to_user_id, is_read);
CREATE INDEX IF NOT EXISTS idx_geometry_types_org ON gpi.geometry_types(organization_id);

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