Compare commits

...

20 Commits

Author SHA1 Message Date
admtracksteel fdec79f81b feat(3d-viewer): replica selecao e hover para modelos WRL com destaque magenta e apontamentos 2026-10-15 10:02:49 -03:00
admtracksteel 9cbc9b383e fix: Alinhamento vertical dos controles 3D - largura uniforme e alinhamento de bordas 2026-10-08 14:19:48 -03:00
admtracksteel a8275bda8c feat: Layout dos botoes 3D - botao de ortogonalidade ao lado do cubo e expansao da luminosidade 2026-10-08 14:03:21 -03:00
admtracksteel 885a076122 feat: Reposicionamento vertical dos controles de luminosidade e enquadrar abaixo do card de giro no Visualizador 3D 2026-10-08 12:14:48 -03:00
admtracksteel 29a4800ff5 feat: Aramado limpo sem linhas internas (EdgesGeometry) no Analisador 3D e compactação de layout da sidebar 3D 2026-10-08 11:52:48 -03:00
admtracksteel fbdf7e379e feat: Ferramenta de rotação 90° e trava de memória no Analisador 3D 2026-10-08 10:45:17 -03:00
admtracksteel 7f4afc5e61 fix: Calibragem do fundo no modo claro do visualizador 3D e prevenção de circular JSON 2026-10-08 10:27:48 -03:00
admtracksteel f42976a09d feat: Novo módulo Analisador 3D BIM & QA/QC (Auditoria de Furação e Comparador de Reviseeeees) 2026-10-08 10:16:48 -03:00
Marcos Reifonas d177184a62 fix(rotas): cadastrar alias /ordens-fabricacao e corrigir botao voltar no cadastro de pecas 2026-10-08 07:32:15 -03:00
Marcos Reifonas 161f73edd6 fix(pecas): destacar marca da peça em vermelho/laranja para revisões sem sobrepor tag comp 2026-10-08 07:25:12 -03:00
Marcos Reifonas 28c23a7324 fix: suporte completo ao tema claro e escuro nos modais de revisoes e botoes da tabela 2026-10-07 23:15:28 -03:00
Marcos Reifonas c239dffdee feat: adicionar gestao de revisoes simples e criticas de pecas com integracao a apontamentos 2026-10-07 22:48:50 -03:00
admtracksteel 8755852fbd feat: add direct db injection button to Bocad Converter 2026-09-29 10:10:47 -03:00
admtracksteel e537d731f3 fix: logic to extract main parts in BocadConverter 2026-09-29 09:55:41 -03:00
admtracksteel 36d5902836 🚀 Auto-deploy: TrackSteel APP atualizado em 25/09/2026 16:49:48 2026-09-25 16:49:48 -03:00
admtracksteel 8e64ad6042 docs: atualizacao da base de conhecimento com as implementacoes de 25/09/2026 2026-09-25 15:11:29 -03:00
admtracksteel 81ba4e8e3e feat: consolidacao matriz de acessos, modais avancados e ocultacao dinamica de menus 2026-09-25 14:58:08 -03:00
admtracksteel 6f595d6839 feat: Alto contraste no modo claro e criacao de templates customizados no Modo Smart 2026-09-25 12:06:27 -03:00
admtracksteel 246e8d08f2 feat: Gestao Avancada de Privilegios e Notificacoes do Modo Smart 2026-09-25 11:18:47 -03:00
admtracksteel 8847c93ac2 feat(infra): reverse replication and update script for marcos-vps 2026-09-25 09:34:46 -03:00
44 changed files with 7804 additions and 278 deletions
+2 -1
View File
@@ -697,7 +697,8 @@ R: Sim, Supabase fornece PostgREST automático. Pode integrar com qualquer siste
## 30. Versões e atualizações deste KB ## 30. Versões e atualizações deste KB
- **v1.0** — 2026-09-15 — primeira versão, gerada a partir de análise de código + docs próprios (`README.md`, `PLANO_MODO_SMART.md`, `migracao_vps_plano.md`) - **v1.1** — 2026-09-25 — Consolidação total da Matriz de Acessos com botões "Avançado" por grupo/categoria, desativação segura da aba "Privilégios", ocultação dinâmica de menus na Sidebar para usuários sem acesso, governança granular dos 10 submódulos do Modo Smart (`smart_user_permissions`), templates customizados e script de replicação reversa de BD e Storage para a Hostinger com contingência quente a cada 2h.
- **v1.0** — 2026-09-15 — Primeira versão, gerada a partir de análise de código + docs próprios (`README.md`, `PLANO_MODO_SMART.md`, `migracao_vps_plano.md`).
--- ---
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
# ==============================================================================
# SCRIPT DE REPLICAÇÃO REVERSA: MARCOS-VPS (PROD) -> HOSTINGER (BACKUP/RÉPLICA)
# ==============================================================================
# Sincroniza schemas de negócio (TS_ERP, gpi, tracksteel, rdo) e storage
# da VPS Local (marcos-vps) para o Supabase na Hostinger (100.106.199.108 / 187.77.227.172).
# ==============================================================================
set -euo pipefail
CYAN='\033[0;36m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
LOG_DIR="/root/Apps/TracksteelApp/scripts/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/sync_db_to_hostinger_$(date +'%Y%m%d').log"
log() {
local msg="[$(date +'%Y-%m-%d %H:%M:%S')] $1"
echo -e "$1"
echo -e "$msg" >> "$LOG_FILE"
}
log "\n${CYAN}======================================================${NC}"
log "${CYAN}🔄 Iniciando Replicação Reversa: marcos-vps -> Hostinger${NC}"
log "${CYAN}======================================================${NC}"
# Containers
SRC_DB_CONTAINER="supabase-db-rjbxu55kggzc59d4cmo133cf"
DEST_DB_CONTAINER="supabase-db-h0oggskgs0ws0sco8kc4s8ws"
# Determinar IP de Conexão com a Hostinger (preferência Tailscale, fallback IP público)
DEST_IP="100.106.199.108"
log "${YELLOW}📡 Testando conexão SSH com Hostinger via Tailscale ($DEST_IP)...${NC}"
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "true" 2>/dev/null; then
log "${YELLOW}⚠️ Tailscale inacessível. Tentando IP público Hostinger (187.77.227.172)...${NC}"
DEST_IP="187.77.227.172"
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "true" 2>/dev/null; then
log "${RED}❌ ERRO: Não foi possível conectar via SSH à Hostinger ($DEST_IP). Replicação abortada.${NC}"
exit 1
fi
fi
log "${GREEN}✅ Conectividade com Hostinger confirmada ($DEST_IP).${NC}"
# Lista de Schemas a sincronizar
SCHEMAS=('TS_ERP' 'gpi' 'tracksteel' 'rdo')
for schema in "${SCHEMAS[@]}"; do
EXISTS=$(docker exec "$SRC_DB_CONTAINER" psql -U postgres -d postgres -tAc \
"SELECT 1 FROM pg_namespace WHERE nspname = '$schema';" 2>/dev/null || echo "0")
if [ "$EXISTS" = "1" ]; then
log "${YELLOW}📦 Sincronizando schema: $schema...${NC}"
TMP_DUMP="/tmp/sync_rev_${schema}_$$.dump"
# 1. Gerar Dump local comprimido (marcos-vps)
docker exec -i "$SRC_DB_CONTAINER" pg_dump -U postgres -d postgres -n "\"$schema\"" -Fc > "$TMP_DUMP"
# 2. Transferir para Hostinger via SCP
scp -q -o BatchMode=yes -o ConnectTimeout=5 "$TMP_DUMP" "root@$DEST_IP:$TMP_DUMP"
# 3. Restaurar na Hostinger e restabelecer permissões
ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" \
"docker cp '$TMP_DUMP' '$DEST_DB_CONTAINER:$TMP_DUMP' && \
(docker exec '$DEST_DB_CONTAINER' pg_restore -U postgres -d postgres --clean --if-exists --no-owner --no-privileges '$TMP_DUMP' || true) && \
docker exec '$DEST_DB_CONTAINER' psql -U postgres -d postgres -c \"GRANT USAGE ON SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role, authenticator; GRANT ALL ON ALL TABLES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; GRANT ALL ON ALL SEQUENCES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; GRANT ALL ON ALL ROUTINES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON TABLES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON SEQUENCES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON ROUTINES TO anon, authenticated, service_role; NOTIFY pgrst, 'reload schema';\" && \
rm -f '$TMP_DUMP' && \
docker exec '$DEST_DB_CONTAINER' rm -f '$TMP_DUMP'"
# 4. Limpar dump local
rm -f "$TMP_DUMP"
log "${GREEN} ↳ Schema $schema replicado na Hostinger com sucesso.${NC}"
fi
done
# Sincronização de Metadados de Storage (buckets e objects)
log "${YELLOW}📁 Sincronizando metadados de Storage (buckets & objects)...${NC}"
STORAGE_SQL="/tmp/sync_storage_rev_$$.sql"
docker exec -i "$SRC_DB_CONTAINER" pg_dump -U postgres -d postgres -t storage.buckets -t storage.objects --data-only --on-conflict-do-nothing > "$STORAGE_SQL" 2>/dev/null || true
if [ -s "$STORAGE_SQL" ]; then
scp -q -o BatchMode=yes -o ConnectTimeout=5 "$STORAGE_SQL" "root@$DEST_IP:$STORAGE_SQL"
ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" \
"docker exec -i '$DEST_DB_CONTAINER' psql -U postgres -d postgres < '$STORAGE_SQL' >/dev/null 2>&1 && rm -f '$STORAGE_SQL'"
rm -f "$STORAGE_SQL"
log "${GREEN} ↳ Metadados de storage sincronizados na Hostinger.${NC}"
else
rm -f "$STORAGE_SQL"
fi
# Sincronização de arquivos físicos de Storage (MinIO)
SRC_STORAGE="/data/coolify/services/rjbxu55kggzc59d4cmo133cf/volumes/storage/"
DEST_STORAGE="/data/coolify/services/h0oggskgs0ws0sco8kc4s8ws/volumes/storage/"
if [ -d "$SRC_STORAGE" ]; then
log "${YELLOW}☁️ Sincronizando arquivos físicos do Storage para a Hostinger...${NC}"
rsync -aqz --delete -e "ssh -o BatchMode=yes -o ConnectTimeout=5" "$SRC_STORAGE" "root@$DEST_IP:$DEST_STORAGE" || true
log "${GREEN} ↳ Arquivos do Storage espelhados com sucesso na Hostinger.${NC}"
fi
# Validação final de integridade de tabelas em TS_ERP
ORIG_COUNT=$(docker exec "$SRC_DB_CONTAINER" psql -U postgres -d postgres -tAc "SELECT count(*) FROM pg_tables WHERE schemaname = 'TS_ERP';" 2>/dev/null || echo "0")
DEST_COUNT=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "docker exec '$DEST_DB_CONTAINER' psql -U postgres -d postgres -tAc \"SELECT count(*) FROM pg_tables WHERE schemaname = 'TS_ERP';\"" 2>/dev/null || echo "0")
log "${CYAN}📊 Validação de Integridade:${NC}"
log " - Tabelas TS_ERP (Origem marcos-vps): $ORIG_COUNT"
log " - Tabelas TS_ERP (Destino Hostinger): $DEST_COUNT"
if [ "$ORIG_COUNT" = "$DEST_COUNT" ] && [ "$ORIG_COUNT" != "0" ]; then
log "${GREEN}✨ Replicação reversa concluída com 100% de paridade!${NC}\n"
else
log "${YELLOW}⚠️ Aviso: Contagem de tabelas ($ORIG_COUNT vs $DEST_COUNT). Verifique os logs.${NC}\n"
fi
+65 -14
View File
@@ -59,6 +59,8 @@ export function AppSidebar() {
'estoque': '#8b5cf6', // roxo 'estoque': '#8b5cf6', // roxo
'ofs': '#eab308', // amarelo 'ofs': '#eab308', // amarelo
'producao': '#ef4444', // vermelho 'producao': '#ef4444', // vermelho
'visualizador-3d': '#8b5cf6', // roxo
'analisador-3d': '#6366f1', // indigo
'painel-industrial': '#06b6d4', // ciano 'painel-industrial': '#06b6d4', // ciano
'expedicao': '#ec4899', // rosa 'expedicao': '#ec4899', // rosa
'obra': '#a3a3a3', // marrom 'obra': '#a3a3a3', // marrom
@@ -100,46 +102,67 @@ export function AppSidebar() {
// Admin can always access everything // Admin can always access everything
if (isAdmin) return true; if (isAdmin) return true;
// Mapeamento de chave de menu/submenu da sidebar para resource_key do BD // Mapeamento completo de chave de menu/submenu da sidebar para resource_key do BD
const itemToResourceMap: Record<string, string> = { const itemToResourceMap: Record<string, string> = {
// PCP & Produção
'pcp': 'producao-pcp', 'pcp': 'producao-pcp',
'dashboard-producao': 'producao-dashboard', 'dashboard-producao': 'producao-dashboard',
'apontamento-producao': 'producao-apontamento', 'apontamento-producao': 'producao-apontamento',
'diario-producao': 'diario-producao', 'diario-producao': 'diario-producao',
'prioridades-fabricacao': 'prioridades-fabricacao', 'prioridades-fabricacao': 'prioridades-fabricacao',
'visao-geral': 'producao-visao', 'visao-geral': 'producao-visao',
'painel-industrial': 'producao-dashboard',
// Engenharia, 3D & OFs
'visualizador-3d': 'visualizador-3d', 'visualizador-3d': 'visualizador-3d',
'analisador-3d': 'analisador-3d',
'ordens-fabricacao': 'ofs-lista', 'ordens-fabricacao': 'ofs-lista',
'ofs-concluidas': 'ofs-lista',
'ficha-tecnica-of': 'cadastro-of', 'ficha-tecnica-of': 'cadastro-of',
'cronograma': 'ofs-cronograma', 'cronograma': 'ofs-cronograma',
'cadastro-pecas': 'cadastro-pecas', 'cadastro-pecas': 'cadastro-pecas',
'equipamentos': 'equipamentos', 'equipamentos': 'equipamentos',
// Expedição & Estoque
'expedicao': 'expedicao', 'expedicao': 'expedicao',
'estoque-main': 'estoque', 'estoque-main': 'estoque',
'estoque': 'estoque', 'estoque': 'estoque',
'solicitacao-compras': 'estoque-solicitacao-compras', 'solicitacao-compras': 'estoque-solicitacao-compras',
// Obra & Campo
'dashboard-obras': 'obra-dashboard', 'dashboard-obras': 'obra-dashboard',
'configuracoes-obra': 'obra-configuracoes', 'configuracoes-obra': 'obra-configuracoes',
'obra': 'obra-dashboard', 'obra': 'obra-dashboard',
// Tarefas & Ferramentas
'tarefas': 'tarefas',
'lista-tarefas': 'tarefas',
'historico-tarefas': 'tarefas',
'ferramentas': 'ferramentas',
'conversores': 'ferramentas-inconsistencias',
'inconsistencias': 'ferramentas-inconsistencias',
// Biblioteca & Sistema
'biblioteca': 'sistema',
'catalogos': 'sistema',
'normas': 'sistema',
'referencias': 'sistema',
'sistema': 'sistema',
'sugestoes': 'sugestoes',
'atribuicoes': 'user-management',
'mapa-interativo': 'dashboard',
'dashboard': 'dashboard',
// Administração & Usuários
'gerenciar-usuarios': 'user-management', 'gerenciar-usuarios': 'user-management',
'user-management': 'user-management', 'user-management': 'user-management',
'configuracoes-gerais': 'configuracoes-gerais', 'configuracoes-gerais': 'configuracoes-gerais',
'inconsistencias': 'ferramentas-inconsistencias', 'personalizacao-tema': 'admin',
'ferramentas': 'ferramentas',
'sistema': 'sistema',
'sugestoes': 'sugestoes',
'admin': 'admin', 'admin': 'admin',
'dashboard': 'dashboard',
}; };
const resKey = itemToResourceMap[itemKey] || itemKey; const resKey = itemToResourceMap[itemKey] || itemKey;
// Se há restrição direta configurada no BD para este recurso // 1. Se há restrição direta configurada no BD para este recurso
if (resourcePermissions && resourcePermissions[resKey]) { if (resourcePermissions && resourcePermissions[resKey]) {
return resourcePermissions[resKey] !== 'no_access'; return resourcePermissions[resKey] !== 'no_access';
} }
// Se o usuário tem perfil de "apenas Smart" (tem modo-smart mas não tem esse recurso) // 2. Se o usuário tem perfil de "apenas Smart" (tem modo-smart mas não tem outros recursos)
const temAcessoSmart = const temAcessoSmart =
resourcePermissions && resourcePermissions &&
resourcePermissions['modo-smart'] && resourcePermissions['modo-smart'] &&
@@ -156,8 +179,23 @@ export function AppSidebar() {
if (soTemSmart && !resKey.startsWith('smart') && resKey !== 'modo-smart') { if (soTemSmart && !resKey.startsWith('smart') && resKey !== 'modo-smart') {
return false; return false;
} }
// 3. Se há permissões individuais salvas na Matriz para este usuário:
// Se a matriz foi configurada para o usuário, qualquer tela que não foi concedida (exceto dashboard básico) fica oculta
if (permsCadastradas.length > 0) {
if (resourcePermissions[resKey] === 'no_access') {
return false;
}
if (resKey !== 'dashboard' && !resKey.startsWith('smart')) {
// Se não consta na lista de permissões ativas concedidas
const perm = resourcePermissions[resKey];
if (!perm || perm === 'no_access') {
return false;
}
}
}
// Se o item requer permissão especial, verificar permissões específicas // 4. Se o item requer permissão especial, verificar permissões específicas
if (requiresSpecialPermission) { if (requiresSpecialPermission) {
if (itemKey === 'ferramentas') { if (itemKey === 'ferramentas') {
return canAccessTools(); return canAccessTools();
@@ -235,12 +273,25 @@ export function AppSidebar() {
{menuGroups.map(group => { {menuGroups.map(group => {
// Filter admin groups for non-admin users // Filter admin groups for non-admin users
if (!isAdmin && group.name === 'Administração') { if (!isAdmin && group.name === 'Administração') {
logger.debug('Ocultando grupo admin para usuário não-admin'); const hasAnyAdminItem = group.items.some(item => canAccessItem(item.key, item.requiresSpecialPermission));
return null; if (!hasAnyAdminItem) {
return null;
}
} }
// Skip groups with no items // Filtrar itens do grupo permitidos para o usuário logado
if (!group.items || group.items.length === 0) { const itemsPermitidos = (group.items || []).filter(item => {
if (item.subItems && item.subItems.length > 0) {
return isAdmin || item.subItems.some(sub => {
if (sub.url === "/admin/theme-customization" && !isAdmin) return false;
return canAccessItem(sub.key, sub.requiresSpecialPermission);
});
}
return isAdmin || canAccessItem(item.key, item.requiresSpecialPermission);
});
// Se não houver itens visíveis no grupo, não renderiza o grupo (nem o título)
if (itemsPermitidos.length === 0) {
return null; return null;
} }
@@ -254,7 +305,7 @@ export function AppSidebar() {
</SidebarGroupLabel> </SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{group.items.map((item) => ( {itemsPermitidos.map((item) => (
<AppSidebarMenuItem <AppSidebarMenuItem
key={item.key || item.title} key={item.key || item.title}
item={item} item={item}
+90 -7
View File
@@ -24,6 +24,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { usePecas } from '@/hooks/usePecas';
export interface BocadConvertedPiece { export interface BocadConvertedPiece {
of: string; of: string;
@@ -39,6 +40,7 @@ export interface BocadConvertedPiece {
perfilPrincipal: string; perfilPrincipal: string;
comprimentoRef: number | string; comprimentoRef: number | string;
qtdComponentesFilhos: number; qtdComponentesFilhos: number;
components?: RawBocadComponent[];
} }
interface RawBocadComponent { interface RawBocadComponent {
@@ -66,8 +68,10 @@ interface RawBocadAssembly {
} }
export const BocadConverter: React.FC = () => { export const BocadConverter: React.FC = () => {
const { importPecas } = usePecas();
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isDirectImporting, setIsDirectImporting] = useState(false);
const [tratamentoGlobal, setTratamentoGlobal] = useState<'pintura' | 'galvanizacao'>('pintura'); const [tratamentoGlobal, setTratamentoGlobal] = useState<'pintura' | 'galvanizacao'>('pintura');
const [extractedPieces, setExtractedPieces] = useState<BocadConvertedPiece[]>([]); const [extractedPieces, setExtractedPieces] = useState<BocadConvertedPiece[]>([]);
const [headerOf, setHeaderOf] = useState<string>(''); const [headerOf, setHeaderOf] = useState<string>('');
@@ -321,11 +325,10 @@ export const BocadConverter: React.FC = () => {
} }
// Critério de identificação da Peça Principal (Linha com Fundo Cinza): // Critério de identificação da Peça Principal (Linha com Fundo Cinza):
// No formato Bocad: // Pela regra do Bocad: Marcas < 1000 são as peças principais (linhas cinzas)
// 1. Linha principal tem 'Marca' preenchida, 'Quant' > 0, 'Perfil' genérico (ex: VIGA) // Marcas >= 1000 são componentes (linhas brancas) que ficam abaixo da peça principal
// 2. E o campo 'Qualid' é VAZIO na linha principal (o aço fica nas linhas de componentes) const marcaNum = parseInt(marcaVal, 10);
// 3. O Peso Total é a somatória do lote da peça const isMainAssemblyRow = !isNaN(marcaNum) && marcaNum < 1000 && marcaVal && quantVal > 0;
const isMainAssemblyRow = !qualidVal && marcaVal && quantVal > 0 && perfilVal;
if (isMainAssemblyRow) { if (isMainAssemblyRow) {
// Se já havia um subconjunto sendo processado, salva-o // Se já havia um subconjunto sendo processado, salva-o
@@ -411,7 +414,8 @@ export const BocadConverter: React.FC = () => {
material: normalizedMat, material: normalizedMat,
perfilPrincipal: perfilPrincipal, perfilPrincipal: perfilPrincipal,
comprimentoRef: compRef, comprimentoRef: compRef,
qtdComponentesFilhos: whiteRowsCount qtdComponentesFilhos: whiteRowsCount,
components: asm.components
}; };
}); });
@@ -516,6 +520,66 @@ export const BocadConverter: React.FC = () => {
} }
}; };
const handleDirectImport = async () => {
if (extractedPieces.length === 0) return;
setIsDirectImporting(true);
try {
const importacaoRows: any[] = [];
extractedPieces.forEach((item) => {
if (item.compostoPorComponentes === 'SIM' && item.components && item.components.length > 0) {
item.components.forEach((comp) => {
importacaoRows.push({
of_number: item.of,
etapa_fase: item.fase,
marca: item.marca,
descricao: item.descricao,
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnitario || 0,
peso_total: item.pesoTotal || 0,
tratamento_superficial: item.tratamentoSuperficial,
material: item.material,
perfil_principal: item.perfilPrincipal,
tem_componentes: true,
marca_componente: comp.marca || '',
descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnit || 0,
quantidade_por_peca: Number(comp.quant) || 1
});
});
} else {
importacaoRows.push({
of_number: item.of,
etapa_fase: item.fase,
marca: item.marca,
descricao: item.descricao,
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnitario || 0,
peso_total: item.pesoTotal || 0,
tratamento_superficial: item.tratamentoSuperficial,
material: item.material,
perfil_principal: item.perfilPrincipal,
tem_componentes: false,
marca_componente: '',
descricao_componente: '',
perfil_componente: '',
peso_unitario_componente: 0,
quantidade_por_peca: 0
});
}
});
await importPecas(importacaoRows);
toast.success(`${extractedPieces.length} peças foram importadas com sucesso!`);
addLog(`✅ Importação concluída: ${extractedPieces.length} peças gravadas no banco.`);
} catch (err: any) {
console.error('Erro na importação direta:', err);
toast.error(`Falha ao importar: ${err?.message || 'Erro no banco'}`);
} finally {
setIsDirectImporting(false);
}
};
const totalPesoGeral = extractedPieces.reduce((acc, curr) => acc + (curr.pesoTotal || 0), 0); const totalPesoGeral = extractedPieces.reduce((acc, curr) => acc + (curr.pesoTotal || 0), 0);
const totalPecasCompostas = extractedPieces.filter((p) => p.compostoPorComponentes === 'SIM').length; const totalPecasCompostas = extractedPieces.filter((p) => p.compostoPorComponentes === 'SIM').length;
const totalPecasSimples = extractedPieces.filter((p) => p.compostoPorComponentes === 'NAO').length; const totalPecasSimples = extractedPieces.filter((p) => p.compostoPorComponentes === 'NAO').length;
@@ -690,11 +754,30 @@ export const BocadConverter: React.FC = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
onClick={exportToOfficialExcel} onClick={exportToOfficialExcel}
className="bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs px-4 h-9 shadow-lg shadow-emerald-950/40" variant="outline"
className="border-emerald-600/60 text-emerald-400 hover:bg-emerald-600/10 font-semibold text-xs px-3.5 h-9"
> >
<Download className="w-4 h-4 mr-1.5" /> <Download className="w-4 h-4 mr-1.5" />
Baixar Planilha Padrão (.xlsx) Baixar Planilha Padrão (.xlsx)
</Button> </Button>
<Button
onClick={handleDirectImport}
disabled={isDirectImporting}
className="bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs px-4 h-9 shadow-lg shadow-indigo-950/40"
>
{isDirectImporting ? (
<>
<RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />
Gravando no Banco...
</>
) : (
<>
<Upload className="w-4 h-4 mr-1.5" />
cadastrar peças na OF
</>
)}
</Button>
</div> </div>
</div> </div>
)} )}
+28 -1
View File
@@ -2,7 +2,8 @@
import React from 'react'; import React from 'react';
import { Table, TableBody, TableHeader } from '@/components/ui/table'; import { Table, TableBody, TableHeader } from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Package } from 'lucide-react'; import { Package, FileText, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Peca } from '@/hooks/usePecas'; import { Peca } from '@/hooks/usePecas';
import { usePecasTable } from '@/hooks/usePecasTable'; import { usePecasTable } from '@/hooks/usePecasTable';
import { TableFilters } from './table/TableFilters'; import { TableFilters } from './table/TableFilters';
@@ -22,6 +23,9 @@ interface PecasTableProps {
onEditPeca?: (peca: Peca) => void; onEditPeca?: (peca: Peca) => void;
onDeleteLastImport?: () => void; onDeleteLastImport?: () => void;
onBatchUpdatePecas?: (pecaIds: string[], updates: any) => Promise<void>; onBatchUpdatePecas?: (pecaIds: string[], updates: any) => Promise<void>;
onOpenRevisaoPeca?: (peca: Peca) => void;
onOpenPainelRevisoes?: () => void;
totalRevisoesOF?: number;
hasRecentImport: boolean; hasRecentImport: boolean;
canEdit?: boolean; canEdit?: boolean;
canDelete?: boolean; canDelete?: boolean;
@@ -36,6 +40,9 @@ export function PecasTable({
onEditPeca, onEditPeca,
onDeleteLastImport, onDeleteLastImport,
onBatchUpdatePecas, onBatchUpdatePecas,
onOpenRevisaoPeca,
onOpenPainelRevisoes,
totalRevisoesOF,
hasRecentImport, hasRecentImport,
canEdit = true, canEdit = true,
canDelete = true, canDelete = true,
@@ -169,6 +176,25 @@ export function PecasTable({
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onOpenPainelRevisoes && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onOpenPainelRevisoes}
className="h-8 border-red-500/50 bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 hover:text-red-700 dark:hover:text-red-300 font-bold flex items-center gap-1.5 transition-all shadow-sm"
title="Abrir Registro e Gestão de Revisões da OF"
>
<FileText className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
<span>REG.REVISOES</span>
{totalRevisoesOF !== undefined && totalRevisoesOF > 0 && (
<span className="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-red-600 dark:bg-red-500 text-white font-black">
{totalRevisoesOF}
</span>
)}
</Button>
)}
{canEdit && onBatchUpdatePecas && ( {canEdit && onBatchUpdatePecas && (
<BatchEditButton <BatchEditButton
selectedPecas={selectedPecas} selectedPecas={selectedPecas}
@@ -209,6 +235,7 @@ export function PecasTable({
onEdit={() => handleEditPeca(peca)} onEdit={() => handleEditPeca(peca)}
onDelete={() => handleDeletePeca(peca.id)} onDelete={() => handleDeletePeca(peca.id)}
onOpenComponentPopup={() => onOpenComponentPopup(peca.id)} onOpenComponentPopup={() => onOpenComponentPopup(peca.id)}
onOpenRevisao={() => onOpenRevisaoPeca?.(peca)}
/> />
))} ))}
</TableBody> </TableBody>
@@ -0,0 +1,473 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import {
FileText,
Search,
Printer,
ExternalLink,
Filter,
RefreshCw
} from 'lucide-react';
import { useRevisoes, Revisao } from '@/hooks/useRevisoes';
import { Peca } from '@/hooks/usePecas';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface PainelRevisoesOFModalProps {
isOpen: boolean;
onClose: () => void;
ofNumber: string;
pecas: Peca[];
onOpenRevisaoPeca: (peca: Peca) => void;
}
export function PainelRevisoesOFModal({
isOpen,
onClose,
ofNumber,
pecas,
onOpenRevisaoPeca
}: PainelRevisoesOFModalProps) {
const { revisoes, loading, carregarRevisoesOF } = useRevisoes(ofNumber);
const [busca, setBusca] = useState('');
const [filtroTipo, setFiltroTipo] = useState<'todos' | 'critica' | 'simples'>('todos');
const [visualizandoRelatorio, setVisualizandoRelatorio] = useState(false);
useEffect(() => {
if (isOpen && ofNumber) {
carregarRevisoesOF(ofNumber);
setVisualizandoRelatorio(false);
}
}, [isOpen, ofNumber, carregarRevisoesOF]);
// Estatísticas
const stats = useMemo(() => {
const total = revisoes.length;
const criticas = revisoes.filter((r) => r.tipo_revisao === 'critica').length;
const simples = revisoes.filter((r) => r.tipo_revisao === 'simples').length;
const pecasUnicas = new Set(revisoes.map((r) => r.peca_id)).size;
return { total, criticas, simples, pecasUnicas };
}, [revisoes]);
// Filtro
const revisoesFiltradas = useMemo(() => {
return revisoes.filter((rev) => {
// Tipo
if (filtroTipo !== 'todos' && rev.tipo_revisao !== filtroTipo) {
return false;
}
// Busca texto
if (busca.trim()) {
const termo = busca.toLowerCase();
const marca = (rev.peca?.marca || '').toLowerCase();
const fase = (rev.peca?.etapa_fase || '').toLowerCase();
const motivo = (rev.descricao_motivo || '').toLowerCase();
const numero = (rev.numero_revisao || '').toLowerCase();
const autor = (rev.usuario_nome || '').toLowerCase();
return (
marca.includes(termo) ||
fase.includes(termo) ||
motivo.includes(termo) ||
numero.includes(termo) ||
autor.includes(termo)
);
}
return true;
});
}, [revisoes, filtroTipo, busca]);
const handleImprimirRelatorio = () => {
window.print();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto bg-background text-foreground border-border p-6 shadow-xl">
<DialogHeader className="border-b border-border pb-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-red-500/10 border border-red-500/30 flex items-center justify-center text-red-600 dark:text-red-400">
<FileText className="w-5 h-5" />
</div>
<div>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<span>Registro Geral de Revisões da OF</span>
<Badge variant="outline" className="font-mono text-amber-600 dark:text-amber-400 border-amber-400/40 bg-amber-500/10 text-sm">
{ofNumber || 'Geral'}
</Badge>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs mt-0.5">
Rastreabilidade técnica, histórico de modificações simples e críticas nas peças desta Ordem de Fabricação.
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setVisualizandoRelatorio(!visualizandoRelatorio)}
className="border-border text-foreground hover:bg-muted flex items-center gap-1.5 h-8 text-xs"
>
<Printer className="w-3.5 h-3.5 text-blue-500" />
{visualizandoRelatorio ? 'Voltar para Tabela' : 'Gerar Relatório'}
</Button>
</div>
</div>
</DialogHeader>
{/* MODO RELATÓRIO IMPRESSÃO */}
{visualizandoRelatorio ? (
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between bg-muted p-3 rounded-lg border border-border">
<span className="text-xs text-muted-foreground">
Pré-visualização do Relatório Técnico para impressão ou exportação.
</span>
<Button
size="sm"
onClick={handleImprimirRelatorio}
className="bg-blue-600 hover:bg-blue-500 text-white text-xs h-7 flex items-center gap-1 shadow-sm"
>
<Printer className="w-3.5 h-3.5" />
Imprimir / Salvar PDF
</Button>
</div>
{/* Documento A4 Formato Relatório */}
<div className="p-6 bg-white text-slate-900 rounded-lg shadow-lg text-xs space-y-4 font-sans border border-slate-200">
{/* Header do Relatório */}
<div className="border-b-2 border-slate-800 pb-3 flex justify-between items-start">
<div>
<h2 className="text-lg font-black tracking-wider text-slate-900">TRACKSTEEL INDUSTRIAL</h2>
<p className="text-[11px] text-slate-600">Sistema Integrado de Gestão & Rastreabilidade de Estruturas</p>
<p className="text-[11px] font-bold text-slate-800 mt-1">RELATÓRIO TÉCNICO DE GESTÃO DE REVISÕES</p>
</div>
<div className="text-right text-[11px] text-slate-700">
<p><strong>OF:</strong> {ofNumber}</p>
<p><strong>Emissão:</strong> {format(new Date(), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}</p>
<p><strong>Total de Revisões:</strong> {stats.total}</p>
</div>
</div>
{/* Tabela do Relatório */}
<table className="w-full border-collapse border border-slate-300 text-[10px]">
<thead>
<tr className="bg-slate-100 text-slate-800">
<th className="border border-slate-300 p-1.5 text-left">Data/Hora</th>
<th className="border border-slate-300 p-1.5 text-left">Peça</th>
<th className="border border-slate-300 p-1.5 text-center">Fase</th>
<th className="border border-slate-300 p-1.5 text-center">Revisão</th>
<th className="border border-slate-300 p-1.5 text-center">Tipo</th>
<th className="border border-slate-300 p-1.5 text-left">Motivo / Descrição</th>
<th className="border border-slate-300 p-1.5 text-left">Campos Alterados</th>
<th className="border border-slate-300 p-1.5 text-left">Responsável</th>
</tr>
</thead>
<tbody>
{revisoes.length === 0 ? (
<tr>
<td colSpan={8} className="p-4 text-center text-slate-500">
Nenhum registro de revisão para esta OF.
</td>
</tr>
) : (
revisoes.map((r) => (
<tr key={r.id} className="hover:bg-slate-50 border-b border-slate-200">
<td className="border border-slate-300 p-1.5 whitespace-nowrap">
{r.data_revisao ? format(new Date(r.data_revisao), 'dd/MM/yy HH:mm') : '-'}
</td>
<td className="border border-slate-300 p-1.5 font-bold">
{r.peca?.marca || '-'}
</td>
<td className="border border-slate-300 p-1.5 text-center">
{r.peca?.etapa_fase || '-'}
</td>
<td className="border border-slate-300 p-1.5 text-center font-bold">
{r.numero_revisao}
</td>
<td className="border border-slate-300 p-1.5 text-center font-semibold">
<span className={r.tipo_revisao === 'critica' ? 'text-red-600 font-bold' : 'text-blue-600 font-bold'}>
{r.tipo_revisao === 'critica' ? 'CRÍTICA' : 'SIMPLES'}
</span>
</td>
<td className="border border-slate-300 p-1.5">
{r.descricao_motivo}
{r.observacoes && <span className="block text-slate-500 italic mt-0.5">Obs: {r.observacoes}</span>}
</td>
<td className="border border-slate-300 p-1.5 font-mono text-[9px]">
{r.campos_alterados?.join(', ') || '-'}
</td>
<td className="border border-slate-300 p-1.5">
{r.usuario_nome || r.usuario_email || 'Sistema'}
</td>
</tr>
))
)}
</tbody>
</table>
{/* Rodapé e Assinaturas */}
<div className="pt-8 border-t border-slate-300 grid grid-cols-2 gap-8 text-center text-[10px] text-slate-600">
<div>
<div className="border-t border-slate-400 w-48 mx-auto mb-1"></div>
<p className="font-bold">Engenharia / PCP</p>
<p>Responsável pelo Lançamento</p>
</div>
<div>
<div className="border-t border-slate-400 w-48 mx-auto mb-1"></div>
<p className="font-bold">Supervisão de Produção</p>
<p>Aceite de Modificação</p>
</div>
</div>
</div>
</div>
) : (
/* MODO PAINEL / TABELA */
<div className="mt-4 space-y-4">
{/* CARDS DE MÉTRICAS */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 rounded-lg bg-card border border-border shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground block">
Total de Revisões
</span>
<span className="text-2xl font-black text-foreground mt-1 block">
{stats.total}
</span>
<span className="text-[10px] text-muted-foreground">histórico completo</span>
</div>
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-red-600 dark:text-red-400 block">
Revisões Críticas
</span>
<span className="text-2xl font-black text-red-700 dark:text-red-300 mt-1 block">
{stats.criticas}
</span>
<span className="text-[10px] text-red-600/80 dark:text-red-400/80">alteraram marca, qtd, peso</span>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30 shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 block">
Revisões Simples
</span>
<span className="text-2xl font-black text-blue-700 dark:text-blue-300 mt-1 block">
{stats.simples}
</span>
<span className="text-[10px] text-blue-600/80 dark:text-blue-400/80">dados informativos</span>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 block">
Peças Afetadas
</span>
<span className="text-2xl font-black text-amber-700 dark:text-amber-300 mt-1 block">
{stats.pecasUnicas}
</span>
<span className="text-[10px] text-amber-600/80 dark:text-amber-400/80">peças distintas</span>
</div>
</div>
{/* FILTROS E BUSCA */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2 flex-1 min-w-[240px]">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={busca}
onChange={(e) => setBusca(e.target.value)}
placeholder="Filtrar por peça, fase, motivo ou autor..."
className="pl-8 bg-background border-input text-xs h-8 text-foreground"
/>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs">
<Filter className="w-3.5 h-3.5 text-muted-foreground" />
<button
type="button"
onClick={() => setFiltroTipo('todos')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
filtroTipo === 'todos' ? 'bg-primary text-primary-foreground font-bold shadow-sm' : 'text-muted-foreground hover:bg-muted'
}`}
>
Todas ({stats.total})
</button>
<button
type="button"
onClick={() => setFiltroTipo('critica')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
filtroTipo === 'critica'
? 'bg-red-600 text-white font-bold shadow-sm'
: 'text-red-600 dark:text-red-400 hover:bg-red-500/10'
}`}
>
Críticas ({stats.criticas})
</button>
<button
type="button"
onClick={() => setFiltroTipo('simples')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
filtroTipo === 'simples'
? 'bg-blue-600 text-white font-bold shadow-sm'
: 'text-blue-600 dark:text-blue-400 hover:bg-blue-500/10'
}`}
>
Simples ({stats.simples})
</button>
<Button
variant="ghost"
size="sm"
onClick={() => carregarRevisoesOF(ofNumber)}
disabled={loading}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
title="Atualizar lista"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* TABELA DE REVISÕES */}
<div className="rounded-md border border-border bg-card overflow-hidden shadow-sm">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="border-border hover:bg-muted/50">
<TableHead className="text-muted-foreground text-xs w-28">Data</TableHead>
<TableHead className="text-muted-foreground text-xs w-20">Peça</TableHead>
<TableHead className="text-muted-foreground text-xs w-16 text-center">Fase</TableHead>
<TableHead className="text-muted-foreground text-xs w-20 text-center">Revisão</TableHead>
<TableHead className="text-muted-foreground text-xs w-20 text-center">Tipo</TableHead>
<TableHead className="text-muted-foreground text-xs">Motivo da Revisão</TableHead>
<TableHead className="text-muted-foreground text-xs">Campos Alterados</TableHead>
<TableHead className="text-muted-foreground text-xs w-28">Autor</TableHead>
<TableHead className="text-muted-foreground text-xs w-16 text-right">Ação</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground text-xs">
Carregando revisões da OF...
</TableCell>
</TableRow>
) : revisoesFiltradas.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground text-xs">
{revisoes.length === 0
? 'Nenhuma revisão cadastrada nesta OF.'
: 'Nenhuma revisão encontrada para os filtros selecionados.'}
</TableCell>
</TableRow>
) : (
revisoesFiltradas.map((r) => {
const pecaCorrespondente = pecas.find((p) => p.id === r.peca_id);
return (
<TableRow key={r.id} className="border-border hover:bg-muted/40 text-xs">
<TableCell className="font-mono text-muted-foreground whitespace-nowrap">
{r.data_revisao ? format(new Date(r.data_revisao), 'dd/MM/yy HH:mm') : '-'}
</TableCell>
<TableCell className="font-mono font-bold text-foreground">
{r.peca?.marca || '-'}
</TableCell>
<TableCell className="text-center text-foreground font-mono">
{r.peca?.etapa_fase || '-'}
</TableCell>
<TableCell className="text-center">
<Badge
variant="outline"
className="font-mono text-[11px] font-bold bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-400/40"
>
{r.numero_revisao}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge
className={`text-[10px] font-bold ${
r.tipo_revisao === 'critica'
? 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/30'
}`}
>
{r.tipo_revisao === 'critica' ? 'Crítica' : 'Simples'}
</Badge>
</TableCell>
<TableCell className="text-foreground">
<span className="font-medium block">{r.descricao_motivo}</span>
{r.observacoes && (
<span className="text-[10px] text-muted-foreground italic block mt-0.5">
{r.observacoes}
</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{r.campos_alterados?.map((c) => (
<span
key={c}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-foreground border border-border"
>
{c}
</span>
))}
</div>
</TableCell>
<TableCell className="text-muted-foreground truncate max-w-[120px]">
{r.usuario_nome || r.usuario_email || 'Sistema'}
</TableCell>
<TableCell className="text-right">
{pecaCorrespondente ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
onClose();
onOpenRevisaoPeca(pecaCorrespondente);
}}
title="Abrir CRUD / Histórico da Peça"
className="h-6 w-6 p-0 text-amber-600 dark:text-amber-400 hover:bg-amber-500/10"
>
<ExternalLink className="w-3.5 h-3.5" />
</Button>
) : (
<span className="text-muted-foreground text-[10px]">-</span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,619 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
AlertTriangle,
History,
ArrowRight,
TrendingUp,
TrendingDown,
Save,
Clock,
User,
Info,
ShieldAlert,
Edit3
} from 'lucide-react';
import { Peca } from '@/hooks/usePecas';
import { useRevisoes, Revisao, TipoRevisao } from '@/hooks/useRevisoes';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface RevisaoPecaModalProps {
isOpen: boolean;
onClose: () => void;
peca: Peca | null;
onPecaAtualizada?: () => void;
}
export function RevisaoPecaModal({
isOpen,
onClose,
peca,
onPecaAtualizada
}: RevisaoPecaModalProps) {
const { carregarRevisoesPeca, detectarTipoRevisao, criarRevisao, loading } = useRevisoes();
const [activeTab, setActiveTab] = useState<'nova' | 'historico'>('nova');
const [historico, setHistorico] = useState<Revisao[]>([]);
const [loadingHistorico, setLoadingHistorico] = useState(false);
// Campos editáveis do formulário
const [formData, setFormData] = useState({
marca: '',
quantidade: 0,
peso_unitario: 0,
peso_total: 0,
descricao: '',
etapa_fase: '',
material: '',
perfil_principal: '',
tratamento_superficial: '',
comprimento: 0
});
// Campos de controle da revisão
const [numeroRevisao, setNumeroRevisao] = useState('Rev 01');
const [motivo, setMotivo] = useState('');
const [observacoes, setObservacoes] = useState('');
const [tipoManual, setTipoManual] = useState<TipoRevisao | null>(null);
// Inicializa os dados quando o modal abre
useEffect(() => {
if (peca && isOpen) {
setFormData({
marca: peca.marca || '',
quantidade: Number(peca.quantidade) || 0,
peso_unitario: Number(peca.peso_unitario) || 0,
peso_total: Number(peca.peso_total) || 0,
descricao: peca.descricao || '',
etapa_fase: peca.etapa_fase || '',
material: peca.material || '',
perfil_principal: peca.perfil_principal || '',
tratamento_superficial: peca.tratamento_superficial || '',
comprimento: Number(peca.comprimento) || 0
});
setMotivo('');
setObservacoes('');
setTipoManual(null);
// Carregar histórico
setLoadingHistorico(true);
carregarRevisoesPeca(peca.id).then((revs) => {
setHistorico(revs);
setLoadingHistorico(false);
// Sugere o próximo número de revisão
const proximoNum = revs.length + 1;
setNumeroRevisao(`Rev ${String(proximoNum).padStart(2, '0')}`);
});
}
}, [peca, isOpen, carregarRevisoesPeca]);
if (!peca) return null;
// Analisa alterações em tempo real
const analise = detectarTipoRevisao(peca, formData);
const tipoEfetivo = tipoManual || analise.tipo;
const diffQtd = Number(formData.quantidade) - Number(peca.quantidade || 0);
const diffPeso = Number(formData.peso_unitario) - Number(peca.peso_unitario || 0);
const handleInputChange = (field: string, value: any) => {
setFormData((prev) => {
const next = { ...prev, [field]: value };
if (field === 'quantidade' || field === 'peso_unitario') {
const q = field === 'quantidade' ? Number(value) : prev.quantidade;
const p = field === 'peso_unitario' ? Number(value) : prev.peso_unitario;
next.peso_total = Number((q * p).toFixed(3));
}
return next;
});
};
const handleSalvarRevisao = async (e: React.FormEvent) => {
e.preventDefault();
if (!motivo.trim()) {
return;
}
const sucesso = await criarRevisao({
peca,
numeroRevisao,
tipoRevisao: tipoEfetivo,
descricaoMotivo: motivo,
observacoes,
novosDados: formData
});
if (sucesso) {
if (onPecaAtualizada) {
onPecaAtualizada();
}
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto bg-background text-foreground border-border p-6 shadow-xl">
<DialogHeader className="border-b border-border pb-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-500">
<Edit3 className="w-5 h-5" />
</div>
<div>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<span>Gestão de Revisões da Peça</span>
<Badge variant="outline" className="font-mono text-amber-600 dark:text-amber-400 border-amber-400/40 bg-amber-500/10 text-sm">
{peca.marca}
</Badge>
{historico.length > 0 && (
<Badge variant="secondary" className="bg-muted text-muted-foreground text-xs">
{historico.length} {historico.length === 1 ? 'revisão gravada' : 'revisões gravadas'}
</Badge>
)}
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs mt-0.5">
OF: <strong className="text-foreground">{peca.of_number}</strong> • Fase: <strong className="text-foreground">{peca.etapa_fase || 'N/A'}</strong> • Qtd Atual: <strong className="text-foreground">{peca.quantidade} un</strong> • Peso Atual: <strong className="text-foreground">{peca.peso_total || (peca.quantidade * peca.peso_unitario)} kg</strong>
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant={tipoEfetivo === 'critica' ? 'destructive' : 'secondary'}
className={`text-xs px-2.5 py-1 font-semibold ${
tipoEfetivo === 'critica'
? 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/30'
}`}
>
{tipoEfetivo === 'critica' ? '⚠️ Revisão Crítica' : 'ℹ️ Revisão Simples'}
</Badge>
</div>
</div>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="mt-4">
<TabsList className="grid grid-cols-2 bg-muted border border-border">
<TabsTrigger value="nova" className="data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm font-semibold">
<Edit3 className="w-4 h-4 mr-2" />
Nova Revisão (Editar Peça)
</TabsTrigger>
<TabsTrigger value="historico" className="data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm font-semibold">
<History className="w-4 h-4 mr-2" />
Histórico de Revisões ({historico.length})
</TabsTrigger>
</TabsList>
{/* ABA 1: NOVA REVISÃO */}
<TabsContent value="nova" className="mt-4 space-y-4">
<form onSubmit={handleSalvarRevisao} className="space-y-4">
{/* Metadados da Revisão */}
<div className="p-4 rounded-lg bg-card border border-border space-y-3 shadow-sm">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Info className="w-3.5 h-3.5 text-amber-500" />
Identificação da Revisão
</h4>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Classificação:</span>
<button
type="button"
onClick={() => setTipoManual('simples')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
tipoEfetivo === 'simples'
? 'bg-blue-600 text-white font-bold shadow-sm'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
Simples
</button>
<button
type="button"
onClick={() => setTipoManual('critica')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
tipoEfetivo === 'critica'
? 'bg-red-600 text-white font-bold shadow-sm'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
Crítica
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Label className="text-xs text-foreground">Número da Revisão *</Label>
<Input
value={numeroRevisao}
onChange={(e) => setNumeroRevisao(e.target.value)}
placeholder="Ex: Rev 01, Rev 02, Rev A"
className="font-mono text-sm mt-1 bg-background border-input text-foreground"
required
/>
</div>
<div className="md:col-span-2">
<Label className="text-xs text-foreground">Motivo / Justificativa da Revisão *</Label>
<Input
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
placeholder="Ex: Ajuste de projeto, alteração de quantidade pelo cliente..."
className="text-sm mt-1 bg-background border-input text-foreground"
required
/>
</div>
</div>
<div>
<Label className="text-xs text-foreground">Observações Adicionais (Opcional)</Label>
<Textarea
value={observacoes}
onChange={(e) => setObservacoes(e.target.value)}
placeholder="Detalhes adicionais, aprovações de engenharia, notas de fábrica..."
rows={2}
className="text-xs mt-1 bg-background border-input text-foreground"
/>
</div>
</div>
{/* Formulário CRUD da Peça */}
<div className="p-4 rounded-lg bg-card border border-border space-y-4 shadow-sm">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Edit3 className="w-3.5 h-3.5 text-blue-500" />
Campos da Peça para Revisão
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{/* Marca */}
<div>
<Label className="text-xs text-foreground flex items-center justify-between">
<span>Marca / Número da Peça *</span>
{formData.marca !== peca.marca && (
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-bold">Alterado!</span>
)}
</Label>
<Input
value={formData.marca}
onChange={(e) => handleInputChange('marca', e.target.value)}
className={`font-mono text-sm mt-1 bg-background border-input text-foreground ${
formData.marca !== peca.marca ? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-200' : ''
}`}
required
/>
</div>
{/* Quantidade */}
<div>
<Label className="text-xs text-foreground flex items-center justify-between">
<span>Quantidade *</span>
{diffQtd !== 0 && (
<span className={`text-[10px] font-bold ${diffQtd > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{diffQtd > 0 ? `+${diffQtd}` : `${diffQtd}`} un
</span>
)}
</Label>
<Input
type="number"
min={1}
step="1"
value={formData.quantidade}
onChange={(e) => handleInputChange('quantidade', Number(e.target.value))}
className={`font-mono text-sm mt-1 bg-background border-input text-foreground ${
diffQtd !== 0
? diffQtd > 0
? 'border-green-500 bg-green-500/10 text-green-700 dark:text-green-200'
: 'border-red-500 bg-red-500/10 text-red-700 dark:text-red-200'
: ''
}`}
required
/>
</div>
{/* Peso Unitário */}
<div>
<Label className="text-xs text-foreground flex items-center justify-between">
<span>Peso Unitário (kg) *</span>
{diffPeso !== 0 && (
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-bold">Alterado</span>
)}
</Label>
<Input
type="number"
step="0.001"
value={formData.peso_unitario}
onChange={(e) => handleInputChange('peso_unitario', Number(e.target.value))}
className="font-mono text-sm mt-1 bg-background border-input text-foreground"
required
/>
</div>
{/* Peso Total (calculado) */}
<div>
<Label className="text-xs text-foreground">Peso Total (kg)</Label>
<Input
type="number"
value={formData.peso_total}
readOnly
disabled
className="font-mono text-sm mt-1 bg-muted border-border text-muted-foreground cursor-not-allowed"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{/* Descrição */}
<div className="md:col-span-2">
<Label className="text-xs text-foreground">Descrição da Peça</Label>
<Input
value={formData.descricao}
onChange={(e) => handleInputChange('descricao', e.target.value)}
placeholder="Ex: Viga I, Pilar Tubular, Perfil Soldado..."
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
{/* Fase / Etapa */}
<div>
<Label className="text-xs text-foreground">Fase / Etapa</Label>
<Input
value={formData.etapa_fase}
onChange={(e) => handleInputChange('etapa_fase', e.target.value)}
placeholder="Ex: 1, 2, Cobertura..."
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
{/* Comprimento */}
<div>
<Label className="text-xs text-foreground">Comprimento (mm)</Label>
<Input
type="number"
value={formData.comprimento}
onChange={(e) => handleInputChange('comprimento', Number(e.target.value))}
placeholder="Ex: 6000"
className="text-sm mt-1 bg-background border-input text-foreground font-mono"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<Label className="text-xs text-foreground">Material / Aço</Label>
<Input
value={formData.material}
onChange={(e) => handleInputChange('material', e.target.value)}
placeholder="Ex: ASTM A36, A572 GR50"
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
<div>
<Label className="text-xs text-foreground">Perfil Principal</Label>
<Input
value={formData.perfil_principal}
onChange={(e) => handleInputChange('perfil_principal', e.target.value)}
placeholder="Ex: W250x32.7, RHS150x6.3"
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
<div>
<Label className="text-xs text-foreground">Tratamento Superficial</Label>
<Input
value={formData.tratamento_superficial}
onChange={(e) => handleInputChange('tratamento_superficial', e.target.value)}
placeholder="Ex: Pintura Epóxi, Galvanizado"
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
</div>
</div>
{/* CARD DE IMPACTO OPERACIONAL / REGRAS DE NEGÓCIO */}
{analise.camposAlterados.length > 0 && (
<div className={`p-4 rounded-lg border shadow-sm ${
tipoEfetivo === 'critica'
? 'bg-red-500/10 border-red-500/30 text-red-900 dark:text-red-200'
: 'bg-blue-500/10 border-blue-500/30 text-blue-900 dark:text-blue-200'
}`}>
<div className="flex items-start gap-3">
<ShieldAlert className={`w-5 h-5 mt-0.5 shrink-0 ${
tipoEfetivo === 'critica' ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
}`} />
<div className="space-y-2 text-xs">
<div className="font-bold uppercase tracking-wider">
{tipoEfetivo === 'critica'
? 'Impacto Crítico na Linha de Produção & Apontamentos'
: 'Revisão Informativa (Campos Subjetivos)'}
</div>
<div className="space-y-1">
{diffQtd > 0 && (
<div className="flex items-center gap-1.5 text-emerald-700 dark:text-emerald-400 font-medium">
<TrendingUp className="w-4 h-4 shrink-0" />
<span>
<strong>Aumento de quantidade (+{diffQtd} un):</strong> A diferença excedente de {diffQtd} un nascerá automaticamente no 1º processo de fabricação (Corte/Detalhamento) para novos apontamentos.
</span>
</div>
)}
{diffQtd < 0 && (
<div className="flex items-center gap-1.5 text-rose-700 dark:text-rose-400 font-medium">
<TrendingDown className="w-4 h-4 shrink-0" />
<span>
<strong>Redução de quantidade ({diffQtd} un):</strong> O novo total será de {formData.quantidade} un. Os processos que possuírem apontamentos superiores a {formData.quantidade} un serão revisados automaticamente para o novo teto.
</span>
</div>
)}
{formData.marca !== peca.marca && (
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-300 font-medium">
<ArrowRight className="w-4 h-4 shrink-0" />
<span>
<strong>Alteração de marca/código ({peca.marca} ➔ {formData.marca}):</strong> A rastreabilidade da peça será atualizada e preservada em apontamentos e romaneios de expedição.
</span>
</div>
)}
{analise.detalhes.map((det, idx) => (
<div key={idx} className="text-foreground/80">
• {det}
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Botões de Ação */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
className="border-border text-foreground hover:bg-muted"
>
Cancelar
</Button>
<Button
type="submit"
disabled={loading || !motivo.trim() || analise.camposAlterados.length === 0}
className="bg-amber-600 hover:bg-amber-500 text-white font-semibold flex items-center gap-2 shadow-sm"
>
<Save className="w-4 h-4" />
{loading ? 'Processando Revisão...' : 'Salvar Revisão e Atualizar Peça'}
</Button>
</div>
</form>
</TabsContent>
{/* ABA 2: HISTÓRICO DE REVISÕES */}
<TabsContent value="historico" className="mt-4 space-y-3">
{loadingHistorico ? (
<div className="p-8 text-center text-muted-foreground">
Carregando histórico de revisões...
</div>
) : historico.length === 0 ? (
<div className="p-8 text-center border border-dashed border-border rounded-lg text-muted-foreground bg-card/50">
<History className="w-8 h-8 mx-auto mb-2 text-muted-foreground/60" />
<p className="font-medium">Nenhuma revisão registrada para esta peça até o momento.</p>
<p className="text-xs text-muted-foreground mt-1">
Ao realizar a primeira alteração pela aba &quot;Nova Revisão&quot;, o histórico completo será guardado aqui.
</p>
</div>
) : (
<div className="space-y-3">
{historico.map((rev) => (
<div
key={rev.id}
className="p-4 rounded-lg bg-card border border-border hover:border-primary/40 transition-colors space-y-2.5 shadow-sm"
>
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono font-bold bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-400/40">
{rev.numero_revisao}
</Badge>
<Badge
className={`text-xs ${
rev.tipo_revisao === 'critica'
? 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/30'
}`}
>
{rev.tipo_revisao === 'critica' ? 'Crítica' : 'Simples'}
</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{rev.data_revisao
? format(new Date(rev.data_revisao), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
: 'N/A'}
</span>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<User className="w-3.5 h-3.5" />
<span>{rev.usuario_nome || rev.usuario_email || 'Usuário'}</span>
</div>
</div>
{/* Motivo */}
<div className="text-sm font-medium text-foreground">
<strong>Motivo:</strong> {rev.descricao_motivo}
</div>
{rev.observacoes && (
<div className="text-xs text-muted-foreground italic">
<strong>Obs:</strong> {rev.observacoes}
</div>
)}
{/* Comparativo de Campos Alterados */}
{rev.campos_alterados && rev.campos_alterados.length > 0 && (
<div className="p-2.5 rounded bg-muted/60 border border-border text-xs space-y-1">
<div className="font-semibold text-muted-foreground text-[11px] uppercase tracking-wider mb-1">
Modificações Realizadas:
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{rev.campos_alterados.map((campo) => {
const ant = rev.dados_anteriores?.[campo];
const nov = rev.dados_novos?.[campo];
return (
<div key={campo} className="p-1.5 rounded bg-background border border-border">
<span className="text-muted-foreground font-mono text-[10px] block uppercase">
{campo}
</span>
<div className="flex items-center gap-1.5 text-xs font-mono mt-0.5">
<span className="text-rose-600 dark:text-rose-400 line-through truncate max-w-[80px]">
{String(ant ?? '-')}
</span>
<ArrowRight className="w-3 h-3 text-muted-foreground shrink-0" />
<span className="text-emerald-600 dark:text-emerald-400 font-bold truncate max-w-[80px]">
{String(nov ?? '-')}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Impacto em Apontamentos */}
{rev.impacto_processos && rev.impacto_processos.resumo && rev.impacto_processos.resumo.length > 0 && (
<div className="p-2 rounded bg-amber-500/10 border border-amber-500/20 text-[11px] text-amber-800 dark:text-amber-300 space-y-0.5">
<div className="font-bold flex items-center gap-1 text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3 h-3" />
Ajustes de Produção Aplicados:
</div>
{rev.impacto_processos.resumo.map((res: string, i: number) => (
<div key={i}>• {res}</div>
))}
</div>
)}
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
+37 -4
View File
@@ -14,7 +14,8 @@ import {
Layers, Layers,
Loader2, Loader2,
ExternalLink, ExternalLink,
Minus Minus,
FileText
} from 'lucide-react'; } from 'lucide-react';
import { Peca } from '@/hooks/usePecas'; import { Peca } from '@/hooks/usePecas';
import { useComponentesPeca } from '@/hooks/useComponentesPeca'; import { useComponentesPeca } from '@/hooks/useComponentesPeca';
@@ -28,6 +29,7 @@ interface TableRowProps {
onOpenComponentPopup: () => void; onOpenComponentPopup: () => void;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void; onDelete?: () => void;
onOpenRevisao?: () => void;
} }
export function TableRow({ export function TableRow({
@@ -36,13 +38,15 @@ export function TableRow({
onSelect, onSelect,
onOpenComponentPopup, onOpenComponentPopup,
onEdit, onEdit,
onDelete onDelete,
onOpenRevisao
}: TableRowProps) { }: TableRowProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const { labels } = useAppLabels(); const { labels } = useAppLabels();
const { componentes, loading } = useComponentesPeca(isExpanded ? peca.id : ''); const { componentes, loading } = useComponentesPeca(isExpanded ? peca.id : '');
const hasSubComponents = peca.tem_componentes; const hasSubComponents = peca.tem_componentes;
const hasRevisao = Boolean(peca.tem_revisao || (peca.qtd_revisoes && peca.qtd_revisoes > 0));
const toggleExpand = (e: React.MouseEvent) => { const toggleExpand = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -88,9 +92,26 @@ export function TableRow({
{peca.etapa_fase} {peca.etapa_fase}
</TableCell> </TableCell>
<TableCell className="w-14 px-2 text-foreground text-xs font-bold font-mono"> <TableCell className="w-14 px-2 text-xs font-bold font-mono">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span>{peca.marca}</span> <span
onClick={hasRevisao && onOpenRevisao ? (e) => {
e.stopPropagation();
onOpenRevisao();
} : undefined}
className={`${
hasRevisao
? 'text-red-600 dark:text-orange-400 font-extrabold cursor-pointer hover:underline'
: 'text-foreground'
}`}
title={
hasRevisao
? `Peça em revisão (${peca.revisao_atual || 'Rev'}). Clique para consultar.`
: undefined
}
>
{peca.marca}
</span>
{hasSubComponents && ( {hasSubComponents && (
<span <span
onClick={toggleExpand} onClick={toggleExpand}
@@ -168,6 +189,18 @@ export function TableRow({
<Package className="h-3.5 w-3.5" /> <Package className="h-3.5 w-3.5" />
</Button> </Button>
{onOpenRevisao && (
<Button
variant="ghost"
size="sm"
onClick={onOpenRevisao}
title="Gestão e Histórico de Revisões da Peça"
className="h-6 w-6 p-0 text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 hover:bg-amber-500/10"
>
<FileText className="h-3 w-3" />
</Button>
)}
{onEdit && ( {onEdit && (
<Button <Button
variant="ghost" variant="ghost"
+10 -8
View File
@@ -27,12 +27,6 @@ export const AppSidebarMenuItem: React.FC<SidebarMenuItemProps> = ({
onMenuItemClick, onMenuItemClick,
getIconProps getIconProps
}) => { }) => {
const canAccess = canAccessItem(item.key);
if (!canAccess && !isAdmin) {
return null; // Don't show item if no access
}
// Filtrar subitens permitidos para o usuário logado // Filtrar subitens permitidos para o usuário logado
const subItemsPermitidos = (item.subItems || []).filter((subItem) => { const subItemsPermitidos = (item.subItems || []).filter((subItem) => {
if (subItem.url === "/admin/theme-customization" && !isAdmin) { if (subItem.url === "/admin/theme-customization" && !isAdmin) {
@@ -42,8 +36,16 @@ export const AppSidebarMenuItem: React.FC<SidebarMenuItemProps> = ({
}); });
// Se o item for apenas um agrupador de subitens e todos estiverem bloqueados, não renderizar o agrupador // Se o item for apenas um agrupador de subitens e todos estiverem bloqueados, não renderizar o agrupador
if (item.subItems && item.subItems.length > 0 && subItemsPermitidos.length === 0 && !isAdmin) { if (item.subItems && item.subItems.length > 0) {
return null; if (!isAdmin && subItemsPermitidos.length === 0) {
return null;
}
} else {
// Se for item direto sem subitens
const canAccess = canAccessItem(item.key);
if (!canAccess && !isAdmin) {
return null;
}
} }
return ( return (
+8 -1
View File
@@ -18,7 +18,8 @@ import {
Settings, Settings,
Shield, Shield,
UserCog, UserCog,
Cog Cog,
ScanSearch
} from "lucide-react"; } from "lucide-react";
import { MenuItem, MenuGroup } from './types'; import { MenuItem, MenuGroup } from './types';
@@ -156,6 +157,12 @@ export const menuGroups: MenuGroup[] = [
url: "/visualizador-3d", url: "/visualizador-3d",
icon: Box, icon: Box,
}, },
{
key: "analisador-3d",
title: "Analisador 3D",
url: "/analisador-3d",
icon: ScanSearch,
},
{ {
key: "painel-industrial", key: "painel-industrial",
title: "Painel Industrial", title: "Painel Industrial",
@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Sparkles, Save, CheckCircle2 } from 'lucide-react';
import { SMART_SUBMODULES } from '@/types/smartPermissions';
interface CriarSmartTemplateModalProps {
isOpen: boolean;
onClose: () => void;
currentPermissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>;
onSalvarTemplate: (
nome: string,
descricao: string,
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
) => boolean;
}
export const CriarSmartTemplateModal: React.FC<CriarSmartTemplateModalProps> = ({
isOpen,
onClose,
currentPermissions,
onSalvarTemplate,
}) => {
const [nome, setNome] = useState('');
const [descricao, setDescricao] = useState('');
const handleSalvar = (e: React.FormEvent) => {
e.preventDefault();
const ok = onSalvarTemplate(nome, descricao, currentPermissions);
if (ok) {
setNome('');
setDescricao('');
onClose();
}
};
// Contagem de permissões ativas
const totalVisualizar = Object.values(currentPermissions).filter((p) => p.can_view).length;
const totalEditar = Object.values(currentPermissions).filter((p) => p.can_edit).length;
const totalExcluir = Object.values(currentPermissions).filter((p) => p.can_delete).length;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md bg-card border-border shadow-2xl rounded-2xl p-6">
<DialogHeader className="space-y-1.5">
<div className="flex items-center gap-2 text-emerald-500">
<div className="p-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<Sparkles className="h-5 w-5" />
</div>
<DialogTitle className="text-base font-bold text-foreground">
Criar Novo Template do Modo Smart
</DialogTitle>
</div>
<DialogDescription className="text-xs text-muted-foreground">
Salve a configuração atual de permissões como um template reutilizável para aplicar em outros operadores com 1 clique.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSalvar} className="space-y-4 py-2">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-slate-200">
Nome do Template <span className="text-rose-500">*</span>
</Label>
<Input
placeholder="Ex: Soldador Nível 2, Inspetor de Pintura, Montador..."
value={nome}
onChange={(e) => setNome(e.target.value)}
required
className="h-9 text-xs bg-background border-slate-300 dark:border-border text-foreground font-medium"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-slate-200">
Descrição Curta (Opcional)
</Label>
<Input
placeholder="Ex: Permissões recomendadas para operadores da bancada de solda"
value={descricao}
onChange={(e) => setDescricao(e.target.value)}
className="h-9 text-xs bg-background border-slate-300 dark:border-border text-foreground font-medium"
/>
</div>
{/* RESUMO DAS PERMISSÕES CAPTURADAS */}
<div className="p-3 rounded-xl border border-border bg-muted/40 space-y-2">
<span className="text-[11px] font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider block">
Permissões capturadas da tela:
</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-sky-500/10 text-sky-700 dark:text-sky-400 border-sky-500/30 text-[10px]">
{totalVisualizar} Visualizar
</Badge>
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/30 text-[10px]">
{totalEditar} Apontar/Editar
</Badge>
<Badge variant="outline" className="bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/30 text-[10px]">
{totalExcluir} Excluir
</Badge>
</div>
</div>
<DialogFooter className="pt-2 flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
className="text-xs h-8"
>
Cancelar
</Button>
<Button
type="submit"
size="sm"
className="text-xs h-8 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold flex items-center gap-1.5 shadow-sm"
>
<Save className="h-3.5 w-3.5" />
Salvar Template
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -2,11 +2,13 @@ import React, { useState } from 'react';
import { useMatrizAcessos, UsuarioMatriz } from '@/hooks/useMatrizAcessos'; import { useMatrizAcessos, UsuarioMatriz } from '@/hooks/useMatrizAcessos';
import { MatrizGrid } from './MatrizGrid'; import { MatrizGrid } from './MatrizGrid';
import { MatrizTemplatesModal } from './MatrizTemplatesModal'; import { MatrizTemplatesModal } from './MatrizTemplatesModal';
import { MatrizSmartAvancadoModal } from './MatrizSmartAvancadoModal';
import { MatrizGrupoAvancadoModal } from './MatrizGrupoAvancadoModal';
import { SimuladorVisaoBar } from './SimuladorVisaoBar'; import { SimuladorVisaoBar } from './SimuladorVisaoBar';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Search, RefreshCw, Users, Shield, Factory, Sparkles } from 'lucide-react'; import { Search, RefreshCw, Users, Shield, Factory, Sparkles, SlidersHorizontal } from 'lucide-react';
interface MatrizAcessosPainelProps { interface MatrizAcessosPainelProps {
onOpenNovoUsuario?: () => void; onOpenNovoUsuario?: () => void;
@@ -28,6 +30,8 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
const [busca, setBusca] = useState(''); const [busca, setBusca] = useState('');
const [selectedUserForTemplate, setSelectedUserForTemplate] = useState<UsuarioMatriz | null>(null); const [selectedUserForTemplate, setSelectedUserForTemplate] = useState<UsuarioMatriz | null>(null);
const [smartAvancadoOpen, setSmartAvancadoOpen] = useState(false);
const [grupoAvancadoKey, setGrupoAvancadoKey] = useState<string | null>(null);
// Filtragem por nome, email ou cargo // Filtragem por nome, email ou cargo
const usuariosFiltrados = usuarios.filter((u) => { const usuariosFiltrados = usuarios.filter((u) => {
@@ -105,6 +109,16 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
</div> </div>
<div className="flex items-center gap-2 w-full sm:w-auto justify-end"> <div className="flex items-center gap-2 w-full sm:w-auto justify-end">
<Button
size="sm"
variant="outline"
onClick={() => setSmartAvancadoOpen(true)}
className="text-xs h-9 border-emerald-500/30 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 font-semibold"
>
<SlidersHorizontal className="h-3.5 w-3.5 mr-1.5" />
Avançado Modo Smart
</Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -139,6 +153,8 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
grupos={gruposModulos} grupos={gruposModulos}
onToggleNivel={toggleNivel} onToggleNivel={toggleNivel}
onOpenTemplates={(user) => setSelectedUserForTemplate(user)} onOpenTemplates={(user) => setSelectedUserForTemplate(user)}
onOpenSmartAvancado={() => setSmartAvancadoOpen(true)}
onOpenGrupoAvancado={(key) => setGrupoAvancadoKey(key)}
/> />
)} )}
@@ -149,6 +165,19 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
onClose={() => setSelectedUserForTemplate(null)} onClose={() => setSelectedUserForTemplate(null)}
onSelectTemplate={aplicarTemplate} onSelectTemplate={aplicarTemplate}
/> />
{/* 6. MODAL DE GESTÃO AVANÇADA MODO SMART */}
<MatrizSmartAvancadoModal
isOpen={smartAvancadoOpen}
onClose={() => setSmartAvancadoOpen(false)}
/>
{/* 7. MODAL DE GESTÃO AVANÇADA POR CATEGORIA / GRUPO */}
<MatrizGrupoAvancadoModal
grupoKey={grupoAvancadoKey}
isOpen={Boolean(grupoAvancadoKey)}
onClose={() => setGrupoAvancadoKey(null)}
/>
</div> </div>
); );
}; };
+38 -2
View File
@@ -3,7 +3,7 @@ import { UsuarioMatriz, GrupoRecurso, NivelAcesso } from '@/hooks/useMatrizAcess
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Sparkles, Eye, Edit3, ShieldAlert } from 'lucide-react'; import { Sparkles, Eye, Edit3, ShieldAlert, SlidersHorizontal } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface MatrizGridProps { interface MatrizGridProps {
@@ -11,6 +11,8 @@ interface MatrizGridProps {
grupos: GrupoRecurso[]; grupos: GrupoRecurso[];
onToggleNivel: (userId: string, resourceKey: string, tipo: 'V' | 'C') => void; onToggleNivel: (userId: string, resourceKey: string, tipo: 'V' | 'C') => void;
onOpenTemplates: (user: UsuarioMatriz) => void; onOpenTemplates: (user: UsuarioMatriz) => void;
onOpenSmartAvancado?: () => void;
onOpenGrupoAvancado?: (grupoKey: string) => void;
} }
export const MatrizGrid: React.FC<MatrizGridProps> = ({ export const MatrizGrid: React.FC<MatrizGridProps> = ({
@@ -18,6 +20,8 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
grupos, grupos,
onToggleNivel, onToggleNivel,
onOpenTemplates, onOpenTemplates,
onOpenSmartAvancado,
onOpenGrupoAvancado,
}) => { }) => {
// Cores de fundo e borda para os grupos // Cores de fundo e borda para os grupos
const corGrupoHeader: Record<string, string> = { const corGrupoHeader: Record<string, string> = {
@@ -29,6 +33,15 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
rose: 'bg-rose-500/10 text-rose-400 border-rose-500/30', rose: 'bg-rose-500/10 text-rose-400 border-rose-500/30',
}; };
const corBotaoAvancado: Record<string, string> = {
emerald: 'bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40',
sky: 'bg-sky-500/20 hover:bg-sky-500/30 text-sky-300 border border-sky-500/40',
indigo: 'bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 border border-indigo-500/40',
amber: 'bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40',
teal: 'bg-teal-500/20 hover:bg-teal-500/30 text-teal-300 border border-teal-500/40',
rose: 'bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 border border-rose-500/40',
};
return ( return (
<div className="rounded-2xl border border-border bg-card overflow-hidden shadow-sm"> <div className="rounded-2xl border border-border bg-card overflow-hidden shadow-sm">
<div className="overflow-x-auto max-w-full"> <div className="overflow-x-auto max-w-full">
@@ -48,8 +61,31 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
colSpan={grupo.recursos.length} colSpan={grupo.recursos.length}
className={`p-2.5 text-center border-r border-border/40 text-xs font-bold ${corGrupoHeader[grupo.cor]}`} className={`p-2.5 text-center border-r border-border/40 text-xs font-bold ${corGrupoHeader[grupo.cor]}`}
> >
<div className="flex items-center justify-center gap-1.5"> <div className="flex items-center justify-center gap-2 flex-wrap">
<span>{grupo.titulo}</span> <span>{grupo.titulo}</span>
{grupo.key === 'smart' && onOpenSmartAvancado ? (
<Button
size="sm"
variant="secondary"
onClick={onOpenSmartAvancado}
className="h-6 px-2 py-0 text-[10px] bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 rounded-md font-semibold flex items-center gap-1 shadow-sm transition-transform active:scale-95"
title="Abrir Gestão Avançada de Privilégios e Notificações do Modo Smart"
>
<SlidersHorizontal className="h-3 w-3" />
Avançado
</Button>
) : onOpenGrupoAvancado ? (
<Button
size="sm"
variant="secondary"
onClick={() => onOpenGrupoAvancado(grupo.key)}
className={`h-6 px-2 py-0 text-[10px] ${corBotaoAvancado[grupo.cor] || 'bg-primary/20 text-primary border-primary/40'} rounded-md font-semibold flex items-center gap-1 shadow-sm transition-transform active:scale-95`}
title={`Abrir Regras & Governança Avançada de ${grupo.titulo}`}
>
<SlidersHorizontal className="h-3 w-3" />
Avançado
</Button>
) : null}
</div> </div>
<span className="text-[10px] font-normal opacity-80 block truncate"> <span className="text-[10px] font-normal opacity-80 block truncate">
{grupo.descricao} {grupo.descricao}
@@ -0,0 +1,409 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
SlidersHorizontal,
Search,
CheckCircle2,
Save,
RotateCcw,
Shield,
Factory,
Box,
Truck,
HardHat,
Sparkles,
Lock,
ArrowUpDown,
Layers,
AlertTriangle,
Download,
Network,
UploadCloud,
Compass,
Barcode,
PackageCheck,
CheckSquare,
Activity,
Settings,
Crown,
Wrench,
Calendar,
Trash2,
FileSpreadsheet,
PlusCircle,
PlusSquare,
Edit3,
FileText,
Printer,
FileInput,
ShoppingCart,
CheckCheck,
AlertCircle,
Settings2,
Users,
ShieldCheck,
KeyRound,
} from 'lucide-react';
import { useGrupoAvancadoPermissions } from '@/hooks/useGrupoAvancadoPermissions';
interface MatrizGrupoAvancadoModalProps {
grupoKey: string | null;
isOpen: boolean;
onClose: () => void;
}
export const MatrizGrupoAvancadoModal: React.FC<MatrizGrupoAvancadoModalProps> = ({
grupoKey,
isOpen,
onClose,
}) => {
const {
config,
loading,
saving,
users,
selectedUserId,
setSelectedUserId,
selectedUser,
toggleRegra,
aplicarTemplate,
salvarConfiguracaoUsuario,
recarregar,
} = useGrupoAvancadoPermissions(isOpen ? grupoKey : null);
const [busca, setBusca] = useState('');
if (!config) return null;
const usersFiltrados = users.filter((u) => {
const termo = busca.toLowerCase();
return (
u.fullName.toLowerCase().includes(termo) ||
u.email.toLowerCase().includes(termo) ||
u.functionName.toLowerCase().includes(termo)
);
});
const corBadge: Record<string, string> = {
sky: 'bg-sky-500/15 text-sky-400 border-sky-500/40',
indigo: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/40',
amber: 'bg-amber-500/15 text-amber-400 border-amber-500/40',
teal: 'bg-teal-500/15 text-teal-400 border-teal-500/40',
rose: 'bg-rose-500/15 text-rose-400 border-rose-500/40',
};
const corFundoIcone: Record<string, string> = {
sky: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
indigo: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
amber: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
teal: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
rose: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
};
const getIconComponent = (icone: string) => {
switch (icone) {
case 'Calendar':
return <Calendar className="h-4 w-4 text-sky-400" />;
case 'Lock':
return <Lock className="h-4 w-4 text-rose-400" />;
case 'ArrowUpDown':
return <ArrowUpDown className="h-4 w-4 text-amber-400" />;
case 'Layers':
return <Layers className="h-4 w-4 text-indigo-400" />;
case 'Trash2':
return <Trash2 className="h-4 w-4 text-rose-400" />;
case 'FileSpreadsheet':
return <FileSpreadsheet className="h-4 w-4 text-emerald-400" />;
case 'PlusCircle':
return <PlusCircle className="h-4 w-4 text-emerald-400" />;
case 'PlusSquare':
return <PlusSquare className="h-4 w-4 text-amber-400" />;
case 'Edit3':
return <Edit3 className="h-4 w-4 text-sky-400" />;
case 'UploadCloud':
return <UploadCloud className="h-4 w-4 text-teal-400" />;
case 'Network':
return <Network className="h-4 w-4 text-indigo-400" />;
case 'Download':
return <Download className="h-4 w-4 text-sky-400" />;
case 'FileText':
return <FileText className="h-4 w-4 text-slate-300" />;
case 'PackageCheck':
return <PackageCheck className="h-4 w-4 text-teal-400" />;
case 'CheckSquare':
return <CheckSquare className="h-4 w-4 text-emerald-400" />;
case 'RotateCcw':
return <RotateCcw className="h-4 w-4 text-amber-400" />;
case 'Printer':
return <Printer className="h-4 w-4 text-indigo-400" />;
case 'SlidersHorizontal':
return <SlidersHorizontal className="h-4 w-4 text-cyan-400" />;
case 'FileInput':
return <FileInput className="h-4 w-4 text-emerald-400" />;
case 'ShoppingCart':
return <ShoppingCart className="h-4 w-4 text-orange-400" />;
case 'CheckCheck':
return <CheckCheck className="h-4 w-4 text-teal-400" />;
case 'AlertCircle':
return <AlertCircle className="h-4 w-4 text-amber-400" />;
case 'Settings2':
return <Settings2 className="h-4 w-4 text-indigo-400" />;
case 'Users':
return <Users className="h-4 w-4 text-sky-400" />;
case 'ShieldCheck':
return <ShieldCheck className="h-4 w-4 text-emerald-400" />;
case 'KeyRound':
return <KeyRound className="h-4 w-4 text-amber-400" />;
case 'Activity':
return <Activity className="h-4 w-4 text-rose-400" />;
case 'Settings':
return <Settings className="h-4 w-4 text-slate-400" />;
case 'Crown':
return <Crown className="h-3.5 w-3.5 text-amber-400" />;
case 'Shield':
return <Shield className="h-3.5 w-3.5 text-sky-400" />;
case 'Wrench':
return <Wrench className="h-3.5 w-3.5 text-emerald-400" />;
case 'Compass':
return <Compass className="h-3.5 w-3.5 text-cyan-400" />;
case 'Barcode':
return <Barcode className="h-3.5 w-3.5 text-amber-400" />;
case 'Box':
return <Box className="h-3.5 w-3.5 text-indigo-400" />;
default:
return <Sparkles className="h-4 w-4 text-amber-400" />;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-5xl w-[95vw] max-h-[92vh] h-[850px] p-0 flex flex-col bg-card border-border shadow-2xl rounded-2xl overflow-hidden">
{/* CABEÇALHO DO MODAL */}
<div className="px-6 py-4 border-b border-border bg-muted/40 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl border ${corFundoIcone[config.cor]}`}>
<SlidersHorizontal className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base sm:text-lg font-bold flex items-center gap-2 text-foreground">
Regras & Governança Avançada
<Badge variant="outline" className={`text-[10px] uppercase tracking-wider font-bold ${corBadge[config.cor]}`}>
{config.titulo}
</Badge>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{config.descricao}
</DialogDescription>
</div>
</div>
</div>
{/* CORPO PRINCIPAL: SIDEBAR DE USUÁRIOS + ÁREA DE REGRAS */}
<div className="flex-1 flex overflow-hidden">
{/* SIDEBAR DE USUÁRIOS */}
<div className="w-[280px] sm:w-[310px] border-r border-border bg-background/60 flex flex-col shrink-0">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Buscar colaborador..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 text-xs h-8 bg-card border-slate-300 dark:border-border text-foreground font-medium"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border/40 p-1.5 space-y-0.5">
{loading ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Carregando colaboradores...
</div>
) : usersFiltrados.length === 0 ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Nenhum colaborador encontrado.
</div>
) : (
usersFiltrados.map((user) => {
const isSelected = user.userId === selectedUserId;
const totalAtivas = Object.values(user.regras).filter(Boolean).length;
return (
<button
key={user.userId}
onClick={() => setSelectedUserId(user.userId)}
className={`w-full text-left p-2.5 rounded-xl transition-all flex items-center gap-2.5 ${
isSelected
? `${corBadge[config.cor]} border shadow-sm`
: 'hover:bg-muted/60 text-muted-foreground hover:text-foreground'
}`}
>
<Avatar className="h-8 w-8 rounded-lg border border-border shrink-0">
<AvatarImage src={user.profileImageUrl || ''} />
<AvatarFallback className="text-[10px] font-bold bg-primary/10 text-primary">
{user.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1">
<span className="text-xs font-semibold text-foreground truncate">
{user.fullName}
</span>
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
{totalAtivas}/{config.regras.length}
</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span className="truncate max-w-[120px]">{user.functionName}</span>
<span>•</span>
<span className="truncate">{user.privilegeName}</span>
</div>
</div>
</button>
);
})
)}
</div>
</div>
{/* ÁREA DE CONFIGURAÇÃO DO USUÁRIO */}
<div className="flex-1 flex flex-col bg-card overflow-hidden">
{selectedUser ? (
<div className="flex-1 flex flex-col overflow-hidden">
{/* CABEÇALHO DO USUÁRIO SELECIONADO */}
<div className="p-4 border-b border-border/60 bg-muted/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 rounded-xl border border-border">
<AvatarImage src={selectedUser.profileImageUrl || ''} />
<AvatarFallback className="text-xs font-bold bg-primary/10 text-primary">
{selectedUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-sm font-bold text-foreground">
{selectedUser.fullName}
</h3>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span>{selectedUser.email}</span>
<span>•</span>
<Badge variant="secondary" className="text-[10px] py-0 h-4">
{selectedUser.functionName}
</Badge>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={salvarConfiguracaoUsuario}
disabled={saving}
className="h-8 text-xs font-semibold bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1.5 shadow-sm"
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Gravando...' : 'Salvar Regras'}
</Button>
</div>
</div>
{/* TEMPLATES RÁPIDOS */}
<div className="px-6 py-3 border-b border-border/40 bg-muted/10">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-semibold text-muted-foreground mr-1 flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5 text-amber-400" />
Presets Rápidos:
</span>
{config.templates.map((tpl) => (
<Button
key={tpl.id}
size="sm"
variant="outline"
onClick={() => aplicarTemplate(tpl.id)}
className="h-7 text-[11px] px-2.5 py-0 rounded-lg hover:border-primary/50 flex items-center gap-1.5 bg-background/80"
title={tpl.descricao}
>
{getIconComponent(tpl.icone)}
{tpl.nome}
</Button>
))}
</div>
</div>
{/* LISTAGEM DE REGRAS GRANULARES */}
<div className="flex-1 overflow-y-auto p-6 space-y-3">
<div className="grid grid-cols-1 gap-3">
{config.regras.map((regra) => {
const ativo = Boolean(selectedUser.regras[regra.key]);
return (
<div
key={regra.key}
onClick={() => toggleRegra(regra.key)}
className={`p-3.5 rounded-xl border transition-all cursor-pointer flex items-center justify-between gap-4 ${
ativo
? 'bg-muted/40 border-primary/40 shadow-sm'
: 'bg-background border-border/50 opacity-70 hover:opacity-100 hover:border-border'
}`}
>
<div className="flex items-center gap-3.5 min-w-0">
<div
className={`p-2 rounded-lg border shrink-0 ${
ativo
? `${corBadge[config.cor]} border`
: 'bg-muted text-muted-foreground border-border'
}`}
>
{getIconComponent(regra.icone)}
</div>
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-foreground">
{regra.nome}
</span>
{ativo && (
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30 text-[9px] h-4 py-0">
LIBERADO
</Badge>
)}
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{regra.descricao}
</p>
</div>
</div>
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Switch
checked={ativo}
onCheckedChange={() => toggleRegra(regra.key)}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-xs">
Selecione um colaborador ao lado para configurar as regras avançadas.
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,603 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
SlidersHorizontal,
Search,
CheckCircle2,
Save,
RotateCcw,
Shield,
Factory,
Eye,
Edit3,
Trash2,
Mail,
Phone,
MessageSquare,
AlertTriangle,
FileText,
Layers,
Flame,
Zap,
Box,
ExternalLink,
Truck,
Wrench,
FileSpreadsheet,
Info,
Sparkles,
Lock,
Plus,
BookmarkPlus,
X,
} from 'lucide-react';
import { useSmartPermissions } from '@/hooks/useSmartPermissions';
import { SMART_SUBMODULES, SmartCustomTemplate } from '@/types/smartPermissions';
import { CriarSmartTemplateModal } from './CriarSmartTemplateModal';
interface MatrizSmartAvancadoModalProps {
isOpen: boolean;
onClose: () => void;
}
export const MatrizSmartAvancadoModal: React.FC<MatrizSmartAvancadoModalProps> = ({
isOpen,
onClose,
}) => {
const {
loading,
saving,
users,
templates,
selectedUserId,
setSelectedUserId,
selectedUser,
togglePermission,
updateNotificationConfig,
salvarConfiguracaoUsuario,
aplicarTemplate,
criarNovoTemplate,
removerTemplateCustom,
recarregar,
} = useSmartPermissions();
const [busca, setBusca] = useState('');
const [modalCriarTemplateOpen, setModalCriarTemplateOpen] = useState(false);
const usersFiltrados = users.filter((u) => {
const termo = busca.toLowerCase();
return (
u.fullName.toLowerCase().includes(termo) ||
u.email.toLowerCase().includes(termo) ||
u.functionName.toLowerCase().includes(termo)
);
});
const getIconComponent = (icone: string) => {
switch (icone) {
case 'FileText':
return <FileText className="h-4 w-4 text-sky-500 dark:text-sky-400" />;
case 'Layers':
return <Layers className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />;
case 'Flame':
return <Flame className="h-4 w-4 text-orange-500 dark:text-orange-400" />;
case 'CheckCircle2':
return <CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />;
case 'Zap':
return <Zap className="h-4 w-4 text-amber-500 dark:text-amber-400" />;
case 'Box':
return <Box className="h-4 w-4 text-cyan-600 dark:text-cyan-400" />;
case 'ExternalLink':
return <ExternalLink className="h-4 w-4 text-purple-600 dark:text-purple-400" />;
case 'Truck':
return <Truck className="h-4 w-4 text-amber-600 dark:text-amber-500" />;
case 'Wrench':
return <Wrench className="h-4 w-4 text-teal-600 dark:text-teal-400" />;
case 'FileSpreadsheet':
return <FileSpreadsheet className="h-4 w-4 text-emerald-600 dark:text-emerald-500" />;
case 'Shield':
return <Shield className="h-3.5 w-3.5 text-rose-500 dark:text-rose-400" />;
case 'Factory':
return <Factory className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />;
case 'Eye':
return <Eye className="h-3.5 w-3.5 text-sky-600 dark:text-sky-400" />;
default:
return <Sparkles className="h-3.5 w-3.5 text-amber-500 dark:text-amber-400" />;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-6xl w-[96vw] max-h-[94vh] h-[900px] p-0 flex flex-col bg-card border-border shadow-2xl rounded-2xl overflow-hidden">
{/* CABEÇALHO DO MODAL */}
<div className="px-6 py-4 border-b border-border bg-muted/40 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<SlidersHorizontal className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base sm:text-lg font-bold flex items-center gap-2 text-foreground">
Gestão Avançada de Privilégios & Notificações
<Badge variant="outline" className="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-emerald-500/40 text-[10px] uppercase tracking-wider font-bold">
Modo Smart
</Badge>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
Controle granular de acesso por submódulo e canais de notificação (E-mail e WhatsApp via única).
</DialogDescription>
</div>
</div>
</div>
{/* CORPO PRINCIPAL COM SIDEBAR DE USUÁRIOS E ÁREA DE CONFIGURAÇÃO */}
<div className="flex-1 flex overflow-hidden">
{/* SIDEBAR DE USUÁRIOS */}
<div className="w-[280px] sm:w-[310px] border-r border-border bg-background/60 flex flex-col shrink-0">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Buscar colaborador..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 text-xs h-8 bg-card border-slate-300 dark:border-border text-foreground font-medium"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border/40 p-1.5 space-y-0.5">
{loading ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Carregando colaboradores...
</div>
) : usersFiltrados.length === 0 ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Nenhum colaborador encontrado.
</div>
) : (
usersFiltrados.map((user) => {
const isSelected = user.userId === selectedUserId;
return (
<button
key={user.userId}
onClick={() => setSelectedUserId(user.userId)}
className={`w-full text-left p-2.5 rounded-xl transition-all flex items-center gap-2.5 ${
isSelected
? 'bg-emerald-500/15 border border-emerald-500/40 text-foreground shadow-sm'
: 'hover:bg-muted/60 text-muted-foreground hover:text-foreground'
}`}
>
<Avatar className="h-8 w-8 rounded-lg border border-border">
<AvatarImage src={user.profileImageUrl || ''} />
<AvatarFallback className="rounded-lg bg-primary/10 text-primary text-xs font-bold">
{user.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className={`text-xs font-bold truncate ${isSelected ? 'text-emerald-700 dark:text-emerald-400 font-extrabold' : 'text-slate-800 dark:text-foreground'}`}>
{user.fullName}
</p>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground truncate">
{user.functionName}
</p>
</div>
{user.whatsappNumber && (
<div className="p-1 rounded-md bg-emerald-500/15 text-emerald-700 dark:text-emerald-400" title={`WhatsApp: ${user.whatsappNumber}`}>
<MessageSquare className="h-3 w-3" />
</div>
)}
</button>
);
})
)}
</div>
</div>
{/* ÁREA DE CONFIGURAÇÃO DO USUÁRIO SELECIONADO */}
<div className="flex-1 flex flex-col bg-card overflow-y-auto">
{selectedUser ? (
<div className="p-5 sm:p-6 space-y-6">
{/* CABEÇALHO DO USUÁRIO + PERFIL */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 rounded-2xl border border-border bg-muted/20">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 rounded-xl border border-border shadow-sm">
<AvatarImage src={selectedUser.profileImageUrl || ''} />
<AvatarFallback className="rounded-xl bg-primary/10 text-primary text-sm font-bold">
{selectedUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-base font-bold text-slate-900 dark:text-foreground">
{selectedUser.fullName}
</h3>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span className="text-slate-600 dark:text-slate-400">{selectedUser.email}</span>
<span>•</span>
<span className="text-emerald-600 dark:text-emerald-400 font-bold">{selectedUser.functionName}</span>
</p>
</div>
</div>
{/* BOTÃO PRINCIPAL DE SALVAR ALTERAÇÕES */}
<Button
size="sm"
onClick={() => salvarConfiguracaoUsuario(selectedUser.userId)}
disabled={saving}
className="text-xs h-9 px-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold shadow-sm flex items-center gap-1.5 transition-transform active:scale-95"
>
<Save className={`h-4 w-4 ${saving ? 'animate-spin' : ''}`} />
{saving ? 'Salvando...' : 'Salvar Alterações'}
</Button>
</div>
{/* BARRA DE TEMPLATES RÁPIDOS & CRIAR NOVO TEMPLATE */}
<div className="p-3.5 rounded-2xl border border-border bg-background/50 space-y-2.5">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
<Sparkles className="h-3.5 w-3.5 text-amber-500" />
Templates de Acesso em 1 Clique
</div>
<Button
size="sm"
variant="outline"
onClick={() => setModalCriarTemplateOpen(true)}
className="text-xs h-7 px-2.5 border-emerald-500/40 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 font-bold flex items-center gap-1 shadow-sm"
>
<Plus className="h-3.5 w-3.5" />
Criar Novo Template
</Button>
</div>
{/* CHIPS DE TEMPLATES */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 max-w-full">
{templates.map((tpl) => (
<div
key={tpl.id}
className="group relative shrink-0 flex items-center"
>
<Button
size="sm"
variant="outline"
onClick={() => aplicarTemplate(selectedUser.userId, tpl)}
className="text-xs h-8 pl-2.5 pr-2.5 rounded-xl border-slate-300 dark:border-border hover:bg-muted/80 flex items-center gap-1.5 shadow-xs font-medium"
title={tpl.descricao}
>
{getIconComponent(tpl.icone)}
<span className="font-semibold text-slate-800 dark:text-slate-200">
{tpl.nome}
</span>
{tpl.badge && (
<Badge
variant="secondary"
className="ml-1 text-[9px] px-1 py-0 h-4 bg-muted text-muted-foreground"
>
{tpl.badge}
</Badge>
)}
</Button>
{/* Botão para deletar template customizado */}
{tpl.isCustom && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removerTemplateCustom(tpl.id);
}}
className="ml-1 p-1 text-muted-foreground hover:text-rose-500 rounded-md transition-colors"
title="Remover este template"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
{/* SEÇÃO 1: MATRIZ DE ACESSO AOS SUBMÓDULOS */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
1. Permissões Granulares nos Submódulos (Modo Smart)
</h4>
<span className="text-[11px] text-slate-500 dark:text-muted-foreground font-medium">
Controle de Visualização, Edição/Apontamento e Exclusão
</span>
</div>
<div className="rounded-2xl border border-border bg-background/50 overflow-hidden shadow-xs">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border/80 bg-muted/40 text-[11px] text-slate-600 dark:text-muted-foreground font-bold">
<th className="p-3">Submódulo / Recurso</th>
<th className="p-3 text-center w-[110px]">
<span className="inline-flex items-center gap-1 text-sky-600 dark:text-sky-400 font-bold">
<Eye className="h-3.5 w-3.5" />
Visualizar
</span>
</th>
<th className="p-3 text-center w-[130px]">
<span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400 font-bold">
<Edit3 className="h-3.5 w-3.5" />
Apontar / Editar
</span>
</th>
<th className="p-3 text-center w-[110px]">
<span className="inline-flex items-center gap-1 text-rose-600 dark:text-rose-400 font-bold">
<Trash2 className="h-3.5 w-3.5" />
Excluir
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40 text-xs">
{SMART_SUBMODULES.map((sub) => {
const perm = selectedUser.permissions[sub.key] || {
can_view: false,
can_edit: false,
can_delete: false,
};
return (
<tr
key={sub.key}
className="hover:bg-muted/30 transition-colors"
>
<td className="p-3">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-card border border-border/60 shadow-xs">
{getIconComponent(sub.icone)}
</div>
<div>
<p className="font-bold text-slate-900 dark:text-foreground text-xs">
{sub.nome}
</p>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
{sub.descricao}
</p>
</div>
</div>
</td>
{/* VISUALIZAR */}
<td className="p-3 text-center">
<div className="flex justify-center">
<Switch
checked={perm.can_view}
onCheckedChange={() =>
togglePermission(selectedUser.userId, sub.key, 'can_view')
}
/>
</div>
</td>
{/* EDITAR / APONTAR */}
<td className="p-3 text-center">
<div className="flex justify-center">
<Switch
checked={perm.can_edit}
onCheckedChange={() =>
togglePermission(selectedUser.userId, sub.key, 'can_edit')
}
/>
</div>
</td>
{/* EXCLUIR */}
<td className="p-3 text-center">
<div className="flex justify-center">
<Switch
checked={perm.can_delete}
onCheckedChange={() =>
togglePermission(selectedUser.userId, sub.key, 'can_delete')
}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* SEÇÃO 2: NOTIFICAÇÕES & CANAL WHATSAPP VIA ÚNICA */}
<div className="space-y-4 pt-2">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
2. Notificações do Usuário (E-mail & WhatsApp BrainSteel)
</h4>
{/* BANNER INFORMATIVO: ALTO CONTRASTE NO MODO CLARO E ESCURO */}
<div className="p-4 rounded-2xl border border-amber-400/80 dark:border-amber-500/30 bg-amber-100/80 dark:bg-amber-500/10 text-amber-950 dark:text-amber-200 flex items-start gap-3 shadow-xs">
<AlertTriangle className="h-5 w-5 text-amber-700 dark:text-amber-400 shrink-0 mt-0.5" />
<div className="text-xs space-y-1">
<p className="font-extrabold text-amber-900 dark:text-amber-300 text-xs">
Comunicação Estritamente Unidirecional (Via Única) no WhatsApp
</p>
<p className="text-amber-950 dark:text-amber-200/90 leading-relaxed text-[11px] font-medium">
O número oficial da BrainSteel é configurado para <strong>somente enviar</strong> alertas, relatórios e métricas de produção. Mensagens de resposta enviadas pelo colaborador não serão recebidas nem respondidas pelo sistema.
</p>
</div>
</div>
{/* GRID DE CONFIGURAÇÕES DE CANAL */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* CANAL WHATSAPP */}
<div className="p-4 rounded-2xl border border-border bg-background/50 space-y-4 shadow-xs">
<div className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400 font-bold text-xs">
<Phone className="h-4 w-4" />
Canal WhatsApp do Colaborador
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-semibold text-slate-700 dark:text-slate-300">
Número de WhatsApp com DDD
</Label>
<Input
placeholder="Ex: 5511999998888 ou (11) 99999-8888"
value={selectedUser.whatsappNumber}
onChange={(e) =>
updateNotificationConfig(
selectedUser.userId,
'whatsappNumber',
e.target.value
)
}
className="h-8 text-xs bg-background dark:bg-card border-slate-300 dark:border-border text-foreground font-medium placeholder:text-slate-500 dark:placeholder:text-muted-foreground/80"
/>
<span className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium block">
Informe o número com DDD para receber avisos automáticos.
</span>
</div>
<div className="pt-2 border-t border-border/40 space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Alertas de Apontamentos
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Notificar sobre novos apontamentos ou avanços forçados
</p>
</div>
<Switch
checked={selectedUser.notifWhatsappApontamentos}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifWhatsappApontamentos',
val
)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Resumo Diário de Métricas
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Receber fechamento diário de produção (peso e peças)
</p>
</div>
<Switch
checked={selectedUser.notifWhatsappMetricas}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifWhatsappMetricas',
val
)
}
/>
</div>
</div>
</div>
{/* CANAL E-MAIL */}
<div className="p-4 rounded-2xl border border-border bg-background/50 space-y-4 shadow-xs">
<div className="flex items-center gap-2 text-sky-600 dark:text-sky-400 font-bold text-xs">
<Mail className="h-4 w-4" />
Canal de E-mail ({selectedUser.email})
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Alertas de Apontamentos por E-mail
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Receber e-mail informativo a cada lote apontado
</p>
</div>
<Switch
checked={selectedUser.notifEmailApontamentos}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifEmailApontamentos',
val
)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Relatórios e Métricas por E-mail
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Receber resumos periódicos e PDFs automáticos
</p>
</div>
<Switch
checked={selectedUser.notifEmailMetricas}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifEmailMetricas',
val
)
}
/>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Selecione um colaborador na barra lateral para configurar.
</div>
)}
</div>
</div>
{/* RODAPÉ DO MODAL */}
<div className="px-6 py-3 border-t border-border bg-muted/40 flex items-center justify-between text-xs">
<span className="text-muted-foreground text-[11px]">
As alterações passam a valer imediatamente no Modo Smart após salvar.
</span>
<Button
variant="outline"
size="sm"
onClick={onClose}
className="text-xs h-8 font-medium"
>
Fechar
</Button>
</div>
</DialogContent>
{/* MODAL PARA SALVAR NOVO TEMPLATE PERSONALIZADO */}
{selectedUser && (
<CriarSmartTemplateModal
isOpen={modalCriarTemplateOpen}
onClose={() => setModalCriarTemplateOpen(false)}
currentPermissions={selectedUser.permissions}
onSalvarTemplate={criarNovoTemplate}
/>
)}
</Dialog>
);
};
@@ -0,0 +1,923 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three-stdlib';
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
import { DefectItem, ColorizationMode } from '@/types/analisador3d';
import { ViewCube } from './ViewCube';
import {
RotateCcw,
RotateCw,
Lock,
Unlock,
Eye,
Maximize2,
Minimize2,
Box,
Layers,
Sparkles,
Search,
Crosshair,
AlertTriangle,
CheckCircle,
HelpCircle,
Cpu,
Focus,
SunMedium,
Compass
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
interface AnalyzerViewer3DProps {
modelData: LoadedIFCResult | null;
defects: DefectItem[];
selectedDefect: DefectItem | null;
selectedPieceMark: string | null;
colorizationMode: ColorizationMode;
onSelectDefect?: (defect: DefectItem | null) => void;
onSelectPiece?: (pieceMark: string | null) => void;
isGhostMode?: boolean;
onToggleGhostMode?: () => void;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
theme?: 'dark' | 'light';
selectedOF?: string;
initialRotation?: { x: number; y: number; z: number } | null;
}
export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
modelData,
defects,
selectedDefect,
selectedPieceMark,
colorizationMode,
onSelectDefect,
onSelectPiece,
isGhostMode = false,
onToggleGhostMode,
isFullscreen = false,
onToggleFullscreen,
theme = 'dark',
selectedOF,
initialRotation,
}) => {
const { toast } = useToast();
const [rotationX, setRotationX] = useState<number>(0);
const [rotationY, setRotationY] = useState<number>(0);
const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.Camera | null>(null);
const perspCameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const orthoCameraRef = useRef<THREE.OrthographicCamera | null>(null);
const [isOrthographic, setIsOrthographic] = useState<boolean>(false);
const controlsRef = useRef<OrbitControls | null>(null);
const modelGroupRef = useRef<THREE.Group | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null);
// Iluminação ajustável em tempo real
const ambientLightRef = useRef<THREE.AmbientLight | null>(null);
const hemiLightRef = useRef<THREE.HemisphereLight | null>(null);
const dirLight1Ref = useRef<THREE.DirectionalLight | null>(null);
const dirLight2Ref = useRef<THREE.DirectionalLight | null>(null);
const [brightness, setBrightness] = useState<number>(100);
const [wireframe, setWireframe] = useState<boolean>(false);
const [hoveredDefect, setHoveredDefect] = useState<DefectItem | null>(null);
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null);
// Mapeamentos rápidos para busca e colorização
const defectByPieceMap = useRef<Map<string, DefectItem>>(new Map());
useEffect(() => {
const map = new Map<string, DefectItem>();
defects.forEach((d) => {
map.set(d.pecaMarca.toUpperCase(), d);
if (d.pecaConectadaMarca) {
map.set(d.pecaConectadaMarca.toUpperCase(), d);
}
});
defectByPieceMap.current = map;
}, [defects]);
// 1. Inicializa o Three.js
useEffect(() => {
if (!canvasContainerRef.current) return;
const width = canvasContainerRef.current.clientWidth || 800;
const height = canvasContainerRef.current.clientHeight || 500;
const scene = new THREE.Scene();
sceneRef.current = scene;
const perspCamera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
perspCamera.position.set(40, 35, 50);
perspCameraRef.current = perspCamera;
const frustumSize = 80;
const aspect = width / height;
const orthoCamera = new THREE.OrthographicCamera(
(frustumSize * aspect) / -2,
(frustumSize * aspect) / 2,
frustumSize / 2,
frustumSize / -2,
0.1,
2000
);
orthoCamera.position.copy(perspCamera.position);
orthoCameraRef.current = orthoCamera;
cameraRef.current = perspCamera;
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance',
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
rendererRef.current = renderer;
canvasContainerRef.current.innerHTML = '';
canvasContainerRef.current.appendChild(renderer.domElement);
const controls = new OrbitControls(perspCamera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.maxDistance = 1500;
controls.minDistance = 0.2;
controlsRef.current = controls;
// Iluminação balanceada industrial com suporte a controle de luminosidade
const ambientLight = new THREE.AmbientLight(0xffffff, 0.85);
scene.add(ambientLight);
ambientLightRef.current = ambientLight;
// Luz hemisférica uniforme: ilumina simultaneamente de cima e baixo para eliminar sombras duras de normais invertidas
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x475569, 0.6);
scene.add(hemiLight);
hemiLightRef.current = hemiLight;
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight1.position.set(80, 120, 80);
scene.add(dirLight1);
dirLight1Ref.current = dirLight1;
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6);
dirLight2.position.set(-80, -40, -80);
scene.add(dirLight2);
dirLight2Ref.current = dirLight2;
// Grid no piso com cores refinadas para modo claro e escuro
const isDarkMode = document.documentElement.classList.contains('dark');
const gridColor1 = isDarkMode ? 0x475569 : 0x94a3b8;
const gridColor2 = isDarkMode ? 0x1e293b : 0xe2e8f0;
const grid = new THREE.GridHelper(100, 50, gridColor1, gridColor2);
grid.position.y = -0.01;
if (grid.material && (grid.material as THREE.Material)) {
(grid.material as THREE.Material).transparent = true;
(grid.material as THREE.Material).opacity = 0.55;
}
scene.add(grid);
gridRef.current = grid;
// Observador para atualizar cores do grid automaticamente quando o usuário alternar tema
const themeObserver = new MutationObserver(() => {
const isDarkNow = document.documentElement.classList.contains('dark');
const centerC = isDarkNow ? new THREE.Color(0x475569) : new THREE.Color(0x94a3b8);
const lineC = isDarkNow ? new THREE.Color(0x1e293b) : new THREE.Color(0xe2e8f0);
if (gridRef.current) {
const colors = gridRef.current.geometry.attributes.color as THREE.BufferAttribute;
if (colors) {
const arr = colors.array as Float32Array;
for (let i = 0; i < arr.length; i += 6) {
const isCenter = i === Math.floor(arr.length / 2);
const c = isCenter ? centerC : lineC;
arr[i] = c.r;
arr[i + 1] = c.g;
arr[i + 2] = c.b;
arr[i + 3] = c.r;
arr[i + 4] = c.g;
arr[i + 5] = c.b;
}
colors.needsUpdate = true;
}
}
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
let animId: number;
const animate = () => {
animId = requestAnimationFrame(animate);
controls.update();
if (renderer && scene && cameraRef.current) {
renderer.render(scene, cameraRef.current);
}
};
animate();
const handleResize = () => {
if (!canvasContainerRef.current || !renderer) return;
const w = canvasContainerRef.current.clientWidth;
const h = canvasContainerRef.current.clientHeight;
if (w <= 0 || h <= 0) return;
const newAspect = w / h;
if (perspCameraRef.current) {
perspCameraRef.current.aspect = newAspect;
perspCameraRef.current.updateProjectionMatrix();
}
if (orthoCameraRef.current) {
const frustum = 80;
orthoCameraRef.current.left = (frustum * newAspect) / -2;
orthoCameraRef.current.right = (frustum * newAspect) / 2;
orthoCameraRef.current.top = frustum / 2;
orthoCameraRef.current.bottom = frustum / -2;
orthoCameraRef.current.updateProjectionMatrix();
}
renderer.setSize(w, h);
};
window.addEventListener('resize', handleResize);
const observer = new ResizeObserver(handleResize);
observer.observe(canvasContainerRef.current);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener('resize', handleResize);
observer.disconnect();
themeObserver.disconnect();
renderer.dispose();
controls.dispose();
};
}, [theme]);
// Efeito dinâmico de ajuste de Luminosidade / Iluminação
useEffect(() => {
const factor = brightness / 100;
if (ambientLightRef.current) {
ambientLightRef.current.intensity = 0.85 * factor;
}
if (hemiLightRef.current) {
hemiLightRef.current.intensity = 0.6 * factor;
}
if (dirLight1Ref.current) {
dirLight1Ref.current.intensity = 1.2 * Math.min(1.4, factor);
}
if (dirLight2Ref.current) {
dirLight2Ref.current.intensity = 0.6 * factor;
}
if (rendererRef.current) {
rendererRef.current.toneMappingExposure = 1.1 * (0.85 + 0.15 * factor);
}
}, [brightness]);
// Alterna entre Câmera Perspectiva e Ortográfica
const handleToggleCamera = useCallback(() => {
if (!perspCameraRef.current || !orthoCameraRef.current || !controlsRef.current) return;
const nextOrtho = !isOrthographic;
setIsOrthographic(nextOrtho);
const prevCamera = cameraRef.current!;
const nextCamera = nextOrtho ? orthoCameraRef.current : perspCameraRef.current;
nextCamera.position.copy(prevCamera.position);
nextCamera.quaternion.copy(prevCamera.quaternion);
cameraRef.current = nextCamera;
controlsRef.current.object = nextCamera;
controlsRef.current.update();
}, [isOrthographic]);
// Centraliza e enquadra perfeitamente o modelo na câmera (Fit View / Fit to Screen)
const fitModelToView = useCallback((targetObj?: THREE.Object3D) => {
const obj = targetObj || modelGroupRef.current;
if (!obj || !cameraRef.current || !controlsRef.current) return;
obj.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(obj);
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim <= 0) return;
const fov = 45 * (Math.PI / 180);
let cameraDistance = Math.abs(maxDim / (2 * Math.tan(fov / 2))) * 1.6;
cameraDistance = Math.max(cameraDistance, 5);
const camera = cameraRef.current;
if ((camera as THREE.PerspectiveCamera).isPerspectiveCamera) {
const pCam = camera as THREE.PerspectiveCamera;
pCam.near = Math.max(0.01, maxDim / 500);
pCam.far = Math.max(2000, maxDim * 20);
pCam.updateProjectionMatrix();
pCam.position.set(center.x + cameraDistance * 0.7, center.y + cameraDistance * 0.5, center.z + cameraDistance * 0.7);
pCam.lookAt(center);
} else if ((camera as THREE.OrthographicCamera).isOrthographicCamera) {
const oCam = camera as THREE.OrthographicCamera;
const aspect = oCam.right / oCam.top;
const frustum = maxDim * 1.5;
oCam.left = (frustum * aspect) / -2;
oCam.right = (frustum * aspect) / 2;
oCam.top = frustum / 2;
oCam.bottom = frustum / -2;
oCam.near = Math.max(0.01, maxDim / 500);
oCam.far = Math.max(2000, maxDim * 20);
oCam.position.set(center.x + maxDim, center.y + maxDim * 0.8, center.z + maxDim);
oCam.lookAt(center);
oCam.updateProjectionMatrix();
}
controlsRef.current.target.copy(center);
controlsRef.current.maxDistance = Math.max(1500, maxDim * 10);
controlsRef.current.update();
if (gridRef.current) {
gridRef.current.position.set(center.x, box.min.y, center.z);
}
}, []);
// 2. Carrega o modelo na cena diretamente sem deep clone circular e aplica rotação
useEffect(() => {
const scene = sceneRef.current;
if (!scene) return;
if (modelGroupRef.current) {
scene.remove(modelGroupRef.current);
modelGroupRef.current = null;
}
if (!modelData || !modelData.sceneGroup) return;
// Usa diretamente a sceneGroup da carga sem clone para evitar erro de circular JSON
const group = modelData.sceneGroup;
modelGroupRef.current = group;
scene.add(group);
// Recupera rotação memorizada para esta OF (localStorage ou banco de dados)
const storageKey = selectedOF ? `tracksteel_model_rot_${selectedOF}` : null;
let savedX = 0;
let savedY = 0;
let isLocked = false;
if (storageKey) {
const stored = localStorage.getItem(storageKey);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (typeof parsed.x === 'number') savedX = parsed.x;
if (typeof parsed.y === 'number') savedY = parsed.y;
isLocked = true;
} catch (e) {}
}
}
if (!isLocked && initialRotation) {
if (typeof initialRotation.x === 'number') savedX = initialRotation.x;
if (typeof initialRotation.y === 'number') savedY = initialRotation.y;
isLocked = true;
}
setRotationX(savedX);
setRotationY(savedY);
setIsRotationLocked(isLocked);
// Aplica rotação inicial no grupo do modelo
group.rotation.set(
THREE.MathUtils.degToRad(savedX),
THREE.MathUtils.degToRad(savedY),
0
);
group.updateMatrixWorld(true);
fitModelToView(group);
}, [modelData, selectedOF, initialRotation, fitModelToView]);
// Gira o modelo em 90 graus no eixo X (tombamento vertical para alinhar Z-Up do CAD com Y-Up)
const handleRotate90X = useCallback(() => {
if (!modelGroupRef.current) return;
const nextX = (rotationX + 90) % 360;
setRotationX(nextX);
setIsRotationLocked(false);
modelGroupRef.current.rotation.x = THREE.MathUtils.degToRad(nextX);
modelGroupRef.current.rotation.y = THREE.MathUtils.degToRad(rotationY);
modelGroupRef.current.updateMatrixWorld(true);
fitModelToView(modelGroupRef.current);
}, [rotationX, rotationY, fitModelToView]);
// Alterna o bloqueio/gravação da posição ideal de visualização no banco e no navegador
const handleToggleLockRotation = useCallback(async () => {
if (!selectedOF) {
toast({
title: 'Aviso',
description: 'Selecione uma OF para memorizar a rotação do modelo.',
});
return;
}
if (!isRotationLocked) {
// Trava e grava a rotação
setIsRotationLocked(true);
const rotData = { x: rotationX, y: rotationY, z: 0 };
// 1. Grava no localStorage para acesso instantâneo
localStorage.setItem(`tracksteel_model_rot_${selectedOF}`, JSON.stringify(rotData));
// 2. Persiste na nuvem/Supabase na tabela ordens_fabricacao
try {
await supabase
.from('ordens_fabricacao' as any)
.update({ model_3d_rotation: rotData })
.eq('num_of', selectedOF);
} catch (err) {
console.error('Erro ao salvar rotação no banco:', err);
}
toast({
title: 'Posição Travada e Salva',
description: `Posição ideal de visualização (${rotationX}°) gravada com sucesso para a OF ${selectedOF}.`,
});
} else {
// Destrava para permitir novo ajuste
setIsRotationLocked(false);
toast({
title: 'Posição Destravada',
description: 'Clique em "Girar 90°" para ajustar e trave no cadeado novamente quando estiver na posição correta.',
});
}
}, [selectedOF, isRotationLocked, rotationX, rotationY, toast]);
// 3. Aplica Colorização Dinâmica com base no modo selecionado e na peça/defeito ativo
useEffect(() => {
if (!modelGroupRef.current) return;
const group = modelGroupRef.current;
const targetMark = selectedPieceMark?.toUpperCase() || selectedDefect?.pecaMarca.toUpperCase();
group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
// Tenta achar a marca associada na userData
const pieceInfo = obj.userData?.pieceInfo as PieceInfo | undefined;
const mark = (pieceInfo?.pieceMark || obj.userData?.pieceMark || '').toUpperCase();
const defect = defectByPieceMap.current.get(mark);
const isSelected = targetMark && (mark === targetMark || (defect && defect.pecaConectadaMarca?.toUpperCase() === targetMark));
let color = '#94a3b8'; // neutro default
let emissiveColor = '#000000';
let emissiveIntensity = 0;
if (colorizationMode === 'status_defeito') {
if (defect) {
if (defect.gravidade === 'critico') color = '#ef4444'; // vermelho
else if (defect.gravidade === 'alerta') color = '#f59e0b'; // laranja/amarelo
else color = '#3b82f6'; // azul
} else {
color = '#10b981'; // verde conforme
}
} else if (colorizationMode === 'diff_revisao') {
if (defect?.tipo === 'peca_nova') color = '#3b82f6'; // azul novo
else if (defect?.tipo === 'peca_excluida') color = '#ef4444'; // vermelho excluído
else if (defect?.tipo === 'peca_modificada' || defect?.tipo === 'deslocamento_furo' || defect?.tipo === 'furo_extra_revisao') {
color = '#f59e0b'; // amarelo modificado
} else {
color = '#10b981'; // verde inalterado
}
} else if (colorizationMode === 'risco_fabril') {
if (defect) {
if (defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto') color = '#dc2626';
else if (defect.riscoFabril === 'medio') color = '#f59e0b';
else color = '#10b981';
} else {
color = '#64748b';
}
} else if (colorizationMode === 'processo') {
color = defect?.processoCor || '#3b82f6';
}
// Se estiver em modo fantasma e houver seleção: atenua peças não selecionadas
let opacity = 1.0;
let transparent = false;
if (isGhostMode && targetMark) {
if (!isSelected) {
opacity = 0.12;
transparent = true;
} else {
opacity = 1.0;
emissiveColor = color;
emissiveIntensity = 0.5;
}
} else if (isSelected) {
emissiveColor = '#ffffff';
emissiveIntensity = 0.35;
}
// Aramado Arquitetural Limpo (EdgesGeometry - Estilo SteelXR / Tekla sem diagonais internas)
if (wireframe) {
// Cria as arestas de contorno se ainda não existirem para esta malha
if (!obj.userData.edgesLine) {
const edgesGeo = new THREE.EdgesGeometry(obj.geometry, 24);
const lineMat = new THREE.LineBasicMaterial({
color: new THREE.Color(color),
transparent: true,
opacity: isGhostMode && targetMark && !isSelected ? 0.15 : 0.95,
});
const edgesLine = new THREE.LineSegments(edgesGeo, lineMat);
edgesLine.name = 'edgesLine';
obj.userData.edgesLine = edgesLine;
obj.add(edgesLine);
} else {
// Atualiza a cor e opacidade das arestas existentes
const lineMat = obj.userData.edgesLine.material as THREE.LineBasicMaterial;
if (lineMat) {
lineMat.color.set(color);
lineMat.opacity = isGhostMode && targetMark && !isSelected ? 0.15 : 0.95;
}
}
obj.userData.edgesLine.visible = true;
// Oculta a malha sólida para exibir apenas o aramado limpo
obj.material = new THREE.MeshBasicMaterial({
visible: false,
wireframe: false,
});
} else {
// Modo Sólido: desativa as linhas de contorno e restaura o sólido PBR
if (obj.userData.edgesLine) {
obj.userData.edgesLine.visible = false;
}
obj.material = new THREE.MeshStandardMaterial({
color: new THREE.Color(color),
emissive: new THREE.Color(emissiveColor),
emissiveIntensity,
roughness: 0.35,
metalness: 0.2,
wireframe: false,
transparent,
opacity,
side: THREE.DoubleSide,
});
}
}
});
}, [colorizationMode, selectedDefect, selectedPieceMark, isGhostMode, wireframe, modelData]);
// 4. Foco na Câmera ao selecionar defeito/peça
const focusOnTarget = useCallback((coords?: { x: number; y: number; z: number }) => {
if (!coords || !cameraRef.current || !controlsRef.current) return;
const target = new THREE.Vector3(coords.x, coords.y, coords.z);
controlsRef.current.target.copy(target);
cameraRef.current.position.set(target.x + 3, target.y + 3, target.z + 4);
controlsRef.current.update();
}, []);
useEffect(() => {
if (selectedDefect && selectedDefect.coordenadas3D) {
focusOnTarget(selectedDefect.coordenadas3D);
}
}, [selectedDefect, focusOnTarget]);
// 5. Clique no 3D (Raycasting)
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!canvasContainerRef.current || !cameraRef.current || !modelGroupRef.current) return;
const rect = canvasContainerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(x, y), cameraRef.current);
const intersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
if (intersects.length > 0) {
const hit = intersects[0].object as THREE.Mesh;
const pieceInfo = hit.userData?.pieceInfo as PieceInfo | undefined;
const mark = pieceInfo?.pieceMark || hit.userData?.pieceMark;
if (mark) {
onSelectPiece?.(mark);
const def = defectByPieceMap.current.get(mark.toUpperCase());
onSelectDefect?.(def || null);
}
}
};
// Reset de Câmera
const handleResetCamera = () => {
if (!modelGroupRef.current || !cameraRef.current || !controlsRef.current) return;
const bbox = new THREE.Box3().setFromObject(modelGroupRef.current);
const size = new THREE.Vector3();
bbox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
cameraRef.current.position.set(maxDim * 1.3, maxDim * 1.1, maxDim * 1.5);
controlsRef.current.target.set(0, size.y / 2, 0);
controlsRef.current.update();
};
// ViewCube Switcher de Direções
const handleViewCubeSelect = useCallback(
(view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => {
if (!controlsRef.current || !cameraRef.current) return;
const target = controlsRef.current.target;
const camera = cameraRef.current;
const distance = camera.position.distanceTo(target);
const newPos = target.clone();
switch (view) {
case 'top':
newPos.y += distance;
break;
case 'bottom':
newPos.y -= distance;
break;
case 'front':
newPos.z += distance;
break;
case 'back':
newPos.z -= distance;
break;
case 'left':
newPos.x -= distance;
break;
case 'right':
newPos.x += distance;
break;
case 'iso':
newPos.x += distance * 0.7;
newPos.y += distance * 0.7;
newPos.z += distance * 0.7;
break;
}
camera.position.copy(newPos);
controlsRef.current.update();
},
[]
);
return (
<div
ref={containerRef}
className="relative w-full h-full select-none overflow-hidden rounded-xl border bg-slate-50 dark:bg-slate-950 border-slate-200 dark:border-slate-800 shadow-sm"
>
{/* Container Three.js */}
<div
ref={canvasContainerRef}
className="w-full h-full cursor-grab active:cursor-grabbing"
onPointerDown={handlePointerDown}
/>
{/* Top Right: ViewCube + Widget de Rotação 90° & Cadeado de Memória */}
<div className="absolute top-3 right-3 z-20 flex flex-col items-end gap-2.5 pointer-events-auto">
{cameraRef.current && controlsRef.current && (
<ViewCube
onSelectView={handleViewCubeSelect}
mainCameraRef={cameraRef as React.MutableRefObject<THREE.Camera | null>}
/>
)}
{/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */}
<div
className="flex items-center gap-1.5 p-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto"
style={{ minWidth: '130px' }}
>
{/* Botão Rotacionar / Tombar 90° */}
<button
onClick={handleRotate90X}
disabled={!modelData}
className={`flex-1 flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[11px] font-bold transition-all duration-150 active:scale-95 ${
modelData
? 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-100 cursor-pointer shadow-sm'
: 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50'
}`}
title="Girar o modelo 90° no eixo de tombamento (adequar orientação Z/Y)"
>
<RotateCw className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
<span className="whitespace-nowrap">Girar 90°</span>
<span className="text-[10px] font-mono font-normal text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-950 px-1 py-0.2 rounded border border-slate-200 dark:border-slate-800">
{rotationX}°
</span>
</button>
{/* Botão Cadeado: Travar / Gravar ou Destravar */}
<button
onClick={handleToggleLockRotation}
disabled={!modelData}
className={`flex items-center justify-center p-1.5 rounded-xl transition-all duration-200 active:scale-95 border ${
!modelData
? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40'
: isRotationLocked
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/60 dark:hover:bg-emerald-900/80 dark:text-emerald-300 dark:border-emerald-700/80 shadow-sm cursor-pointer'
: 'bg-amber-50 hover:bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/50 dark:hover:bg-amber-900/70 dark:text-amber-300 dark:border-amber-700/70 shadow-sm cursor-pointer animate-pulse'
}`}
title={
isRotationLocked
? 'Posição TRAVADA e salva para esta OF. Clique para destravar e ajustar.'
: 'Posição DESTRAVADA. Clique aqui no cadeado para travar e salvar a posição ideal.'
}
>
{isRotationLocked ? (
<Lock className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
) : (
<Unlock className="w-4 h-4 text-amber-600 dark:text-amber-400" />
)}
</button>
</div>
</div>
{/* Barra de Ferramentas Flutuante Superior */}
<div className="absolute top-3 left-3 flex flex-wrap items-center gap-1.5 p-1.5 bg-white/95 dark:bg-slate-900/90 backdrop-blur-md border border-slate-200 dark:border-slate-700/80 rounded-xl shadow-md z-20">
{/* Reset Câmera */}
<button
onClick={handleResetCamera}
title="Resetar Câmera"
className="p-1.5 text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-300 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition"
>
<RotateCcw className="w-4 h-4" />
</button>
{/* Centralizar e Enquadrar (Fit to Screen) */}
<button
onClick={() => fitModelToView()}
title="Centralizar e Enquadrar Estrutura na Tela (Fit to Screen)"
className="p-1.5 text-slate-600 hover:text-cyan-600 hover:bg-cyan-50 dark:text-slate-300 dark:hover:text-cyan-400 dark:hover:bg-cyan-950/40 rounded-lg transition"
>
<Focus className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
</button>
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-700 mx-0.5" />
{/* Comutar Perspectiva / Ortogonal */}
<button
onClick={handleToggleCamera}
title={isOrthographic ? 'Mudar para Vista em Perspectiva (3D Cônica)' : 'Mudar para Vista Ortogonal (Paralela Técnica)'}
className={`flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded-lg transition ${
isOrthographic
? 'bg-emerald-600 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`}
>
<Compass className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span>
</button>
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-700 mx-0.5" />
{/* Modo Raio-X / Foco */}
<button
onClick={onToggleGhostMode}
title={isGhostMode ? 'Desativar Modo Raio-X / Foco' : 'Ativar Modo Raio-X / Foco'}
className={`flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded-lg transition ${
isGhostMode
? 'bg-indigo-600 text-white shadow'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`}
>
<Eye className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Foco / Raio-X</span>
</button>
{/* Aramado Limpo (EdgesGeometry) */}
<button
onClick={() => setWireframe(!wireframe)}
title={wireframe ? 'Mudar para Modelo Sólido' : 'Mudar para Aramado Limpo de Engenharia (sem linhas internas)'}
className={`p-1.5 rounded-lg transition ${
wireframe
? 'bg-indigo-600 text-white shadow-xs'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`}
>
{wireframe ? <Layers className="w-4 h-4" /> : <Box className="w-4 h-4" />}
</button>
{onToggleFullscreen && (
<button
onClick={onToggleFullscreen}
title={isFullscreen ? 'Sair da Tela Cheia' : 'Tela Cheia'}
className="p-1.5 text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-300 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition"
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-700 mx-0.5" />
{/* Barra de Luminosidade (Slider Interativo) */}
<div
className="flex items-center gap-1.5 px-2 py-1 bg-slate-100/90 dark:bg-slate-800/90 rounded-lg border border-slate-200 dark:border-slate-700"
title="Ajuste de Luminosidade da Estrutura (arraste para clarear ou suavizar sombras das peças)"
>
<button
type="button"
onClick={() => setBrightness(100)}
title="Clique para resetar luminosidade em 100%"
className="hover:scale-110 active:scale-95 transition-transform"
>
<SunMedium className="w-3.5 h-3.5 text-amber-500 shrink-0" />
</button>
<input
type="range"
min="40"
max="220"
step="5"
value={brightness}
onChange={(e) => setBrightness(Number(e.target.value))}
className="w-16 sm:w-20 h-1.5 bg-slate-300 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
/>
<span className="text-[10px] font-mono font-bold text-slate-600 dark:text-slate-300 min-w-[28px] text-right">
{brightness}%
</span>
</div>
</div>
{/* Legenda de Cores Flutuante Inferior */}
<div className="absolute bottom-3 left-3 flex flex-wrap items-center gap-3 px-3 py-2 bg-white/95 dark:bg-slate-900/90 backdrop-blur-md border border-slate-200 dark:border-slate-700/80 rounded-lg shadow-md text-xs z-20">
<span className="text-slate-500 dark:text-slate-400 font-medium">Legenda:</span>
{colorizationMode === 'status_defeito' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-sm" />
<span className="text-slate-700 dark:text-slate-300">Conforme</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 shadow-sm" />
<span className="text-slate-700 dark:text-slate-300">Alerta (Deslocado)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 shadow-sm" />
<span className="text-slate-700 dark:text-slate-300">Crítico (Furo Cego/Colisão)</span>
</div>
</>
)}
{colorizationMode === 'diff_revisao' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<span className="text-slate-700 dark:text-slate-300">Inalterada</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<span className="text-slate-700 dark:text-slate-300">Modificada</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<span className="text-slate-700 dark:text-slate-300">Nova</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
<span className="text-slate-700 dark:text-slate-300">Excluída</span>
</div>
</>
)}
{colorizationMode === 'risco_fabril' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<span className="text-slate-700 dark:text-slate-300">Baixo Risco (Sem Corte)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<span className="text-slate-700 dark:text-slate-300">Médio (Em Corte)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-600 animate-pulse" />
<span className="text-slate-700 dark:text-slate-300">Crítico (Soldada/Expedida)</span>
</div>
</>
)}
</div>
{/* Alerta de elemento selecionado flutuante */}
{selectedPieceMark && (
<div className="absolute top-3 left-[225px] px-3 py-1.5 bg-indigo-50 dark:bg-indigo-950/90 border border-indigo-200 dark:border-indigo-700/80 rounded-lg shadow-md text-xs flex items-center gap-2 z-20">
<Crosshair className="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400 animate-spin" style={{ animationDuration: '6s' }} />
<span className="text-indigo-900 dark:text-indigo-200">
Foco Ativo: <strong className="text-indigo-700 dark:text-white font-mono">{selectedPieceMark}</strong>
</span>
<button
onClick={() => {
onSelectPiece?.(null);
onSelectDefect?.(null);
}}
className="ml-1 text-indigo-500 hover:text-indigo-800 dark:hover:text-white"
title="Limpar seleção"
>
×
</button>
</div>
)}
</div>
);
};
+248 -67
View File
@@ -6,7 +6,7 @@ import { ViewCube } from './ViewCube';
import { Viewer3DSidebar } from './Viewer3DSidebar'; import { Viewer3DSidebar } from './Viewer3DSidebar';
import { PieceHoverTooltip } from './PieceHoverTooltip'; import { PieceHoverTooltip } from './PieceHoverTooltip';
import { SelectionListModal, SelectedPieceItem } from './SelectionListModal'; import { SelectionListModal, SelectedPieceItem } from './SelectionListModal';
import { Trash2, FileSpreadsheet, RotateCcw, RotateCw, Lock, Unlock } from 'lucide-react'; import { Trash2, FileSpreadsheet, RotateCcw, RotateCw, Lock, Unlock, Focus, SunMedium, Compass } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
@@ -20,6 +20,53 @@ interface ProductionPieceStatus {
processOrdem?: number; processOrdem?: number;
} }
/**
* Busca de apontamento e status fabril com máxima tolerância e amostragem de chaves para IFC e WRL.
*/
function findProductionDataForPiece(
productionData?: Map<string, ProductionPieceStatus>,
piece?: {
pieceMark?: string;
cleanMark?: string;
assemblyMark?: string;
cleanAssemblyMark?: string;
phase?: string;
} | null,
selectedOF?: string
): ProductionPieceStatus | undefined {
if (!productionData || !piece) return undefined;
const pmark = String(piece.pieceMark || '').trim().toUpperCase();
const cleanMark = String(piece.cleanMark || pmark).trim().toUpperCase();
const assMark = String(piece.assemblyMark || '').trim().toUpperCase();
const cleanAssMark = String(piece.cleanAssemblyMark || cleanMark).trim().toUpperCase();
const elementPhase = piece.phase ? String(piece.phase).trim() : '';
const searchKeys = [
elementPhase && selectedOF ? `${selectedOF}-${elementPhase}-${cleanAssMark}` : '',
elementPhase ? `${elementPhase}-${cleanAssMark}` : '',
elementPhase && selectedOF ? `${selectedOF}-${elementPhase}-${cleanMark}` : '',
elementPhase ? `${elementPhase}-${cleanMark}` : '',
selectedOF ? `${selectedOF}-${cleanAssMark}` : '',
selectedOF ? `${selectedOF}-${cleanMark}` : '',
assMark,
pmark,
cleanAssMark,
cleanMark,
].filter(Boolean).map((k) => k.toUpperCase());
for (const key of searchKeys) {
const match = productionData.get(key);
if (match) {
if (!elementPhase || !match.fase || String(match.fase) === String(elementPhase)) {
return match;
}
}
}
return undefined;
}
interface ModelViewer3DProps { interface ModelViewer3DProps {
modelData: LoadedIFCResult | null; modelData: LoadedIFCResult | null;
productionData?: Map<string, ProductionPieceStatus>; productionData?: Map<string, ProductionPieceStatus>;
@@ -59,6 +106,13 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const [colorMode, setColorMode] = useState<'description' | 'production'>('production'); const [colorMode, setColorMode] = useState<'description' | 'production'>('production');
const [selectedProcess, setSelectedProcess] = useState<string>('all'); const [selectedProcess, setSelectedProcess] = useState<string>('all');
// Iluminação ajustável em tempo real
const ambientLightRef = useRef<THREE.AmbientLight | null>(null);
const hemiLightRef = useRef<THREE.HemisphereLight | null>(null);
const dirLight1Ref = useRef<THREE.DirectionalLight | null>(null);
const dirLight2Ref = useRef<THREE.DirectionalLight | null>(null);
const [brightness, setBrightness] = useState<number>(100);
// Hover Tooltip States (1.0 second delay) // Hover Tooltip States (1.0 second delay)
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null); const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
const [hoveredPiece, setHoveredPiece] = useState<PieceInfo | null>(null); const [hoveredPiece, setHoveredPiece] = useState<PieceInfo | null>(null);
@@ -161,18 +215,22 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
// 5. Lights // 5. Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75); const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
scene.add(ambientLight); scene.add(ambientLight);
ambientLightRef.current = ambientLight;
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2); const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight1.position.set(100, 200, 100); dirLight1.position.set(100, 200, 100);
dirLight1.castShadow = true; dirLight1.castShadow = true;
scene.add(dirLight1); scene.add(dirLight1);
dirLight1Ref.current = dirLight1;
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6); const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6);
dirLight2.position.set(-100, -50, -100); dirLight2.position.set(-100, -50, -100);
scene.add(dirLight2); scene.add(dirLight2);
dirLight2Ref.current = dirLight2;
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x1e293b, 0.5); const hemiLight = new THREE.HemisphereLight(0xffffff, 0x1e293b, 0.5);
scene.add(hemiLight); scene.add(hemiLight);
hemiLightRef.current = hemiLight;
// 6. Floor Grid (Cores refinadas e ultrafinas para modo claro e escuro) // 6. Floor Grid (Cores refinadas e ultrafinas para modo claro e escuro)
const updateGridTheme = (gridHelper: THREE.GridHelper) => { const updateGridTheme = (gridHelper: THREE.GridHelper) => {
@@ -278,6 +336,16 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false); const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false);
const { toast } = useToast(); const { toast } = useToast();
// Efeito dinâmico de ajuste de Luminosidade / Iluminação no ModelViewer3D
useEffect(() => {
const factor = brightness / 100;
if (ambientLightRef.current) ambientLightRef.current.intensity = 0.75 * factor;
if (hemiLightRef.current) hemiLightRef.current.intensity = 0.5 * factor;
if (dirLight1Ref.current) dirLight1Ref.current.intensity = 1.2 * Math.min(1.4, factor);
if (dirLight2Ref.current) dirLight2Ref.current.intensity = 0.6 * factor;
if (rendererRef.current) rendererRef.current.toneMappingExposure = 1.0 * (0.85 + 0.15 * factor);
}, [brightness]);
// Function: Fit Model to View // Function: Fit Model to View
const fitModelToView = useCallback((group?: THREE.Group | null) => { const fitModelToView = useCallback((group?: THREE.Group | null) => {
const targetGroup = group || modelGroupRef.current; const targetGroup = group || modelGroupRef.current;
@@ -700,7 +768,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
} }
// Se a peça física pertencer à seleção permanente, preserva a cor magenta // Se a peça física pertencer à seleção permanente, preserva a cor magenta
const ifcId = mesh.userData.ifcId; const ifcId = mesh.userData.ifcId ?? mesh.userData.expressID;
if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) { if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) {
mesh.userData.originalMaterial = mesh.material; mesh.userData.originalMaterial = mesh.material;
mesh.material = magentaMaterialRef.current; mesh.material = magentaMaterialRef.current;
@@ -868,17 +936,21 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
} }
if (hitMesh) { if (hitMesh) {
const expressID = hitMesh.userData.ifcId; const expressID = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
const pmark = hitMesh.userData.pieceMark; const pmark = hitMesh.userData.pieceMark;
if (modelData && expressID !== undefined) { if (modelData && expressID !== undefined) {
const piece = modelData.pieceByExpressID.get(expressID) || { const piece = modelData.pieceByExpressID.get(expressID) || {
expressID, expressID,
guid: '', guid: hitMesh.userData.guid || '',
name: hitMesh.userData.section || 'Peça', name: hitMesh.userData.name || hitMesh.userData.section || 'Peça',
type: 'PIECE', type: hitMesh.userData.type || 'PIECE',
pieceMark: pmark, pieceMark: pmark,
section: hitMesh.userData.section, cleanMark: hitMesh.userData.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark,
section: hitMesh.userData.section || hitMesh.userData.name,
phase: hitMesh.userData.phase,
}; };
setHoveredPiece(piece); setHoveredPiece(piece);
setHoverPosition({ x: clientX, y: clientY }); setHoverPosition({ x: clientX, y: clientY });
@@ -935,7 +1007,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
} }
if (hitMesh && hitMesh.visible) { if (hitMesh && hitMesh.visible) {
const targetIfcId = hitMesh.userData.ifcId; const targetIfcId = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
if (targetIfcId === undefined) return; if (targetIfcId === undefined) return;
const nextMap = new Map(selectedElementsMapRef.current); const nextMap = new Map(selectedElementsMapRef.current);
@@ -951,16 +1023,19 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
}); });
nextMap.delete(targetIfcId); nextMap.delete(targetIfcId);
} else { } else {
// Marcar peça física inteira: localiza e pinta todas as sub-malhas do mesmo ifcId // Marcar peça física inteira: localiza e pinta todas as sub-malhas do mesmo targetIfcId
const relatedMeshes: THREE.Mesh[] = []; const relatedMeshes: THREE.Mesh[] = [];
if (modelGroupRef.current) { if (modelGroupRef.current) {
modelGroupRef.current.traverse((child) => { modelGroupRef.current.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.userData.ifcId === targetIfcId) { if ((child as THREE.Mesh).isMesh) {
const m = child as THREE.Mesh; const childId = child.userData.ifcId ?? child.userData.expressID;
m.userData.originalMaterial = m.material; if (childId === targetIfcId) {
m.userData.isMagentaSelected = true; const m = child as THREE.Mesh;
m.material = magentaMaterialRef.current; m.userData.originalMaterial = m.material;
relatedMeshes.push(m); m.userData.isMagentaSelected = true;
m.material = magentaMaterialRef.current;
relatedMeshes.push(m);
}
} }
}); });
} }
@@ -968,10 +1043,13 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const pmark = hitMesh.userData.pieceMark; const pmark = hitMesh.userData.pieceMark;
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || { const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || {
expressID: targetIfcId, expressID: targetIfcId,
guid: '', guid: hitMesh.userData.guid || '',
name: hitMesh.userData.section || 'Peça', name: hitMesh.userData.name || hitMesh.userData.section || 'Peça',
type: 'PIECE', type: hitMesh.userData.type || 'PIECE',
pieceMark: pmark, pieceMark: pmark,
cleanMark: hitMesh.userData.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark,
section: hitMesh.userData.section, section: hitMesh.userData.section,
phase: hitMesh.userData.phase, phase: hitMesh.userData.phase,
}; };
@@ -997,8 +1075,32 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1; const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1; const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycasterRef.current.setFromCamera(new THREE.Vector2(mouseX, mouseY), activeCameraRef.current); const raycaster = raycasterRef.current;
const intersects = raycasterRef.current.intersectObjects(modelGroupRef.current.children, true); raycaster.params.Line = { threshold: 4.0 };
raycaster.params.Points = { threshold: 4.0 };
raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), activeCameraRef.current);
let intersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
// Amostragem circular magnética de tolerância para peças esbeltas/finas
if (intersects.length === 0) {
const radiusOffsetsPx = [6, 12, 18];
const angles = [0, Math.PI / 4, Math.PI / 2, (3 * Math.PI) / 4, Math.PI, (5 * Math.PI) / 4, (3 * Math.PI) / 2, (7 * Math.PI) / 4];
for (const rPx of radiusOffsetsPx) {
if (intersects.length > 0) break;
for (const angle of angles) {
const dx = ((rPx * Math.cos(angle)) / rect.width) * 2;
const dy = -((rPx * Math.sin(angle)) / rect.height) * 2;
const samplePos = new THREE.Vector2(mouseX + dx, mouseY + dy);
raycaster.setFromCamera(samplePos, activeCameraRef.current);
const sampleIntersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
if (sampleIntersects.length > 0) {
intersects = sampleIntersects;
break;
}
}
}
}
if (intersects.length > 0) { if (intersects.length > 0) {
const hitPoint = intersects[0].point; const hitPoint = intersects[0].point;
@@ -1012,36 +1114,49 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
if (hitMesh) { if (hitMesh) {
const targetPieceMark = String(hitMesh.userData.pieceMark || '').trim().toUpperCase(); const targetPieceMark = String(hitMesh.userData.pieceMark || '').trim().toUpperCase();
const targetCleanMark = String(hitMesh.userData.cleanMark || targetPieceMark).trim().toUpperCase();
const targetAssemblyMark = String(hitMesh.userData.assemblyMark || '').trim().toUpperCase();
const targetCleanAssMark = String(hitMesh.userData.cleanAssemblyMark || targetCleanMark).trim().toUpperCase();
const targetPhase = hitMesh.userData.phase ? String(hitMesh.userData.phase).trim() : ''; const targetPhase = hitMesh.userData.phase ? String(hitMesh.userData.phase).trim() : '';
if (targetPieceMark) { if (targetPieceMark || targetCleanMark) {
const nextMap = new Map(selectedElementsMapRef.current); const nextMap = new Map(selectedElementsMapRef.current);
// Agrupa todas as sub-malhas por elemento físico (ifcId único) // Agrupa todas as sub-malhas por elemento físico único (ifcId ?? expressID)
const meshesByIfcId = new Map<number, { meshes: THREE.Mesh[]; sampleMesh: THREE.Mesh }>(); const meshesByElementId = new Map<number, { meshes: THREE.Mesh[]; sampleMesh: THREE.Mesh }>();
modelGroupRef.current.traverse((child) => { modelGroupRef.current.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.visible) { if ((child as THREE.Mesh).isMesh && child.visible) {
const mesh = child as THREE.Mesh; const mesh = child as THREE.Mesh;
const pmark = String(mesh.userData.pieceMark || '').trim().toUpperCase(); const pmark = String(mesh.userData.pieceMark || '').trim().toUpperCase();
const cmark = String(mesh.userData.cleanMark || pmark).trim().toUpperCase();
const amark = String(mesh.userData.assemblyMark || '').trim().toUpperCase();
const cleanAm = String(mesh.userData.cleanAssemblyMark || cmark).trim().toUpperCase();
const phase = mesh.userData.phase ? String(mesh.userData.phase).trim() : ''; const phase = mesh.userData.phase ? String(mesh.userData.phase).trim() : '';
// Mesma marca e mesma fase // Mesma marca (peça, conjunto ou limpa) e mesma fase (se especificada)
const isMatch = pmark === targetPieceMark && (!targetPhase || !phase || phase === targetPhase); const isMarkMatch =
if (isMatch) { (pmark && pmark === targetPieceMark) ||
const ifcId = mesh.userData.ifcId; (cmark && cmark === targetCleanMark) ||
if (ifcId !== undefined) { (targetAssemblyMark && amark === targetAssemblyMark) ||
if (!meshesByIfcId.has(ifcId)) { (targetCleanAssMark && cleanAm === targetCleanAssMark);
meshesByIfcId.set(ifcId, { meshes: [], sampleMesh: mesh });
const isPhaseMatch = !targetPhase || !phase || phase === targetPhase;
if (isMarkMatch && isPhaseMatch) {
const elId = mesh.userData.ifcId ?? mesh.userData.expressID;
if (elId !== undefined) {
if (!meshesByElementId.has(elId)) {
meshesByElementId.set(elId, { meshes: [], sampleMesh: mesh });
} }
meshesByIfcId.get(ifcId)!.meshes.push(mesh); meshesByElementId.get(elId)!.meshes.push(mesh);
} }
} }
} }
}); });
// Pinta e adiciona cada peça física real como 1 unidade // Pinta e adiciona cada peça física real como 1 unidade
meshesByIfcId.forEach(({ meshes, sampleMesh }, ifcId) => { meshesByElementId.forEach(({ meshes, sampleMesh }, elId) => {
meshes.forEach((m) => { meshes.forEach((m) => {
if (!m.userData.isMagentaSelected) { if (!m.userData.isMagentaSelected) {
m.userData.originalMaterial = m.material; m.userData.originalMaterial = m.material;
@@ -1050,18 +1165,21 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
m.material = magentaMaterialRef.current; m.material = magentaMaterialRef.current;
}); });
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(ifcId)) || { const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(elId)) || {
expressID: ifcId, expressID: elId,
guid: '', guid: sampleMesh.userData.guid || '',
name: sampleMesh.userData.section || 'Peça', name: sampleMesh.userData.name || sampleMesh.userData.section || 'Peça',
type: 'PIECE', type: sampleMesh.userData.type || 'PIECE',
pieceMark: sampleMesh.userData.pieceMark, pieceMark: sampleMesh.userData.pieceMark,
cleanMark: sampleMesh.userData.cleanMark,
assemblyMark: sampleMesh.userData.assemblyMark,
cleanAssemblyMark: sampleMesh.userData.cleanAssemblyMark,
section: sampleMesh.userData.section, section: sampleMesh.userData.section,
phase: sampleMesh.userData.phase || targetPhase, phase: sampleMesh.userData.phase || targetPhase,
}; };
nextMap.set(ifcId, { nextMap.set(elId, {
expressID: ifcId, expressID: elId,
piece, piece,
meshes, meshes,
}); });
@@ -1097,7 +1215,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const selectedPiecesList: SelectedPieceItem[] = Array.from(selectedElementsMap.values()).map( const selectedPiecesList: SelectedPieceItem[] = Array.from(selectedElementsMap.values()).map(
({ expressID, piece }) => { ({ expressID, piece }) => {
const pmark = piece.pieceMark || 'Sem Marca'; const pmark = piece.pieceMark || 'Sem Marca';
const prod = productionData?.get(pmark); const prod = findProductionDataForPiece(productionData, piece, selectedOF);
return { return {
id: String(expressID), id: String(expressID),
pieceMark: pmark, pieceMark: pmark,
@@ -1142,20 +1260,58 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
onSelectProcess={setSelectedProcess} onSelectProcess={setSelectedProcess}
onFitView={() => fitModelToView()} onFitView={() => fitModelToView()}
onToggleFullscreen={handleToggleFullscreen} onToggleFullscreen={handleToggleFullscreen}
brightness={brightness}
onBrightnessChange={setBrightness}
/> />
{/* Top Right: ViewCube + Painel de Ações de Seleção de Peças */} {/* Top Right: ViewCube + Controles Rápidos de Câmera + Painel de Ações de Seleção de Peças */}
<div className="absolute top-4 right-4 z-20 flex flex-col items-end gap-2.5 pointer-events-auto"> <div className="absolute top-4 right-4 z-20 flex flex-col items-stretch gap-2 pointer-events-auto w-[140px]">
<ViewCube onSelectView={handleViewCubeSelect} mainCameraRef={activeCameraRef} /> {/* Bloco Superior: Botões de Navegação + ViewCube perfeitamente alinhados */}
<div className="w-full flex items-start justify-between">
{/* Botões Rápidos de Navegação ao lado do Cubo (Ortogonalidade e Enquadrar) */}
<div className="flex flex-col gap-1.5 items-center justify-center pt-0.5">
{/* Botão Alternar Vista Ortogonal / Perspectiva */}
<button
onClick={handleToggleCamera}
className={`w-9 h-9 flex items-center justify-center rounded-2xl backdrop-blur-md border transition-all duration-150 active:scale-95 shadow-lg ${
isOrthographic
? 'bg-emerald-500 text-white border-emerald-400 shadow-emerald-500/30 dark:bg-emerald-600 dark:border-emerald-500'
: 'bg-white/95 dark:bg-slate-900/95 text-slate-700 dark:text-slate-200 border-slate-300 dark:border-slate-700 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-slate-100 dark:hover:bg-slate-800 shadow-slate-300/30 dark:shadow-slate-950/50'
}`}
title={
isOrthographic
? 'Câmera Ortogonal (Paralela Técnica) Ativa. Clique para mudar para Perspectiva.'
: 'Câmera Perspectiva (3D Cônica) Ativa. Clique para mudar para Ortogonal.'
}
>
<Compass className={`w-4 h-4 ${isOrthographic ? 'text-white' : 'text-emerald-600 dark:text-emerald-400'}`} />
</button>
{/* Botão Enquadrar / Centralizar Estrutura (Fit to Screen) */}
<button
onClick={() => fitModelToView(modelGroupRef.current)}
disabled={!modelData}
className={`w-9 h-9 flex items-center justify-center rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 transition-all duration-150 active:scale-95 ${
modelData
? 'text-slate-700 hover:text-cyan-600 dark:text-slate-200 dark:hover:text-cyan-400 hover:bg-slate-100 dark:hover:bg-slate-800 cursor-pointer'
: 'text-slate-400 dark:text-slate-600 opacity-40 cursor-not-allowed'
}`}
title="Centralizar e Enquadrar Estrutura na Janela (Fit to Screen)"
>
<Focus className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
</button>
</div>
<ViewCube onSelectView={handleViewCubeSelect} mainCameraRef={activeCameraRef} />
</div>
{/* Card de Controle de Seleção Flutuante Harmonizado Abaixo do Cubo */} {/* Card de Controle de Seleção Flutuante Harmonizado Abaixo do Cubo */}
<div <div
className={`flex flex-col items-stretch gap-1.5 p-2 rounded-2xl backdrop-blur-md border transition-all duration-200 shadow-lg ${ className={`w-full flex flex-col items-stretch gap-1.5 p-2 rounded-2xl backdrop-blur-md border transition-all duration-200 shadow-lg ${
selectedCount > 0 selectedCount > 0
? 'bg-white/95 dark:bg-slate-900/95 border-slate-300 dark:border-slate-700 shadow-slate-300/40 dark:shadow-slate-950/60 ring-1 ring-blue-500/20' ? 'bg-white/95 dark:bg-slate-900/95 border-slate-300 dark:border-slate-700 shadow-slate-300/40 dark:shadow-slate-950/60 ring-1 ring-blue-500/20'
: 'bg-white/80 dark:bg-slate-900/70 border-slate-200 dark:border-slate-800/80 opacity-75 hover:opacity-100' : 'bg-white/80 dark:bg-slate-900/70 border-slate-200 dark:border-slate-800/80 opacity-75 hover:opacity-100'
}`} }`}
style={{ minWidth: '130px' }}
> >
{/* Badge Indicador de Seleção Ativa Sóbrio */} {/* Badge Indicador de Seleção Ativa Sóbrio */}
{selectedCount > 0 && ( {selectedCount > 0 && (
@@ -1167,11 +1323,11 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
</div> </div>
)} )}
{/* Botão Gerar Lista da Seleção (Azul Escuro no claro / Azul Elegante no escuro) */} {/* Botão Gerar Lista da Seleção */}
<button <button
onClick={() => setIsSelectionModalOpen(true)} onClick={() => setIsSelectionModalOpen(true)}
disabled={selectedCount === 0} disabled={selectedCount === 0}
className={`flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[11px] font-bold transition-all duration-200 cursor-pointer ${ className={`w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-xl text-[11px] font-bold transition-all duration-200 cursor-pointer ${
selectedCount > 0 selectedCount > 0
? 'bg-blue-950 hover:bg-blue-900 text-white shadow-sm dark:bg-blue-600 dark:hover:bg-blue-500 scale-[1.02] active:scale-[0.98]' ? 'bg-blue-950 hover:bg-blue-900 text-white shadow-sm dark:bg-blue-600 dark:hover:bg-blue-500 scale-[1.02] active:scale-[0.98]'
: 'bg-slate-100 dark:bg-slate-800/50 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-800 cursor-not-allowed opacity-50' : 'bg-slate-100 dark:bg-slate-800/50 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-800 cursor-not-allowed opacity-50'
@@ -1186,11 +1342,11 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
<span className="whitespace-nowrap">Gerar lista</span> <span className="whitespace-nowrap">Gerar lista</span>
</button> </button>
{/* Botão Limpar Seleção Harmonizado */} {/* Botão Limpar Seleção */}
<button <button
onClick={handleClearSelection} onClick={handleClearSelection}
disabled={selectedCount === 0} disabled={selectedCount === 0}
className={`flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-xl text-[10px] font-semibold transition-all duration-200 cursor-pointer ${ className={`w-full flex items-center justify-center gap-1.5 px-2 py-1 rounded-xl text-[10px] font-semibold transition-all duration-200 cursor-pointer ${
selectedCount > 0 selectedCount > 0
? 'text-slate-600 hover:text-rose-600 hover:bg-rose-50 border border-slate-200 dark:border-slate-700 dark:text-slate-400 dark:hover:text-rose-300 dark:hover:bg-rose-950/30 active:scale-[0.98]' ? 'text-slate-600 hover:text-rose-600 hover:bg-rose-50 border border-slate-200 dark:border-slate-700 dark:text-slate-400 dark:hover:text-rose-300 dark:hover:bg-rose-950/30 active:scale-[0.98]'
: 'text-slate-400 dark:text-slate-600 border border-transparent cursor-not-allowed opacity-40' : 'text-slate-400 dark:text-slate-600 border border-transparent cursor-not-allowed opacity-40'
@@ -1203,24 +1359,21 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
</div> </div>
{/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */} {/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */}
<div <div className="w-full flex items-center gap-1.5 p-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto">
className="flex items-center gap-1.5 p-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto"
style={{ minWidth: '130px' }}
>
{/* Botão Rotacionar / Tombar 90° */} {/* Botão Rotacionar / Tombar 90° */}
<button <button
onClick={handleRotate90X} onClick={handleRotate90X}
disabled={!modelData} disabled={!modelData}
className={`flex-1 flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[11px] font-bold transition-all duration-150 active:scale-95 ${ className={`flex-1 min-w-0 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-xl text-[10px] font-bold transition-all duration-150 active:scale-95 ${
modelData modelData
? 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-100 cursor-pointer shadow-sm' ? 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-100 cursor-pointer shadow-sm'
: 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50' : 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50'
}`} }`}
title="Girar o modelo 90° no eixo de tombamento (adequar orientação Z/Y)" title="Girar o modelo 90° no eixo de tombamento (adequar orientação Z/Y)"
> >
<RotateCw className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" /> <RotateCw className="w-3 h-3 text-blue-600 dark:text-blue-400 shrink-0" />
<span className="whitespace-nowrap">Girar 90°</span> <span className="whitespace-nowrap">Girar 90°</span>
<span className="text-[10px] font-mono font-normal text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-950 px-1 py-0.2 rounded border border-slate-200 dark:border-slate-800"> <span className="text-[9px] font-mono font-normal text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-950 px-1 py-0.2 rounded border border-slate-200 dark:border-slate-800 shrink-0">
{rotationX}° {rotationX}°
</span> </span>
</button> </button>
@@ -1229,7 +1382,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
<button <button
onClick={handleToggleLockRotation} onClick={handleToggleLockRotation}
disabled={!modelData} disabled={!modelData}
className={`flex items-center justify-center p-1.5 rounded-xl transition-all duration-200 active:scale-95 border ${ className={`flex items-center justify-center p-1.5 rounded-xl transition-all duration-200 active:scale-95 border shrink-0 ${
!modelData !modelData
? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40' ? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40'
: isRotationLocked : isRotationLocked
@@ -1243,28 +1396,56 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
} }
> >
{isRotationLocked ? ( {isRotationLocked ? (
<Lock className="w-4 h-4 text-emerald-600 dark:text-emerald-400" /> <Lock className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
) : ( ) : (
<Unlock className="w-4 h-4 text-amber-600 dark:text-amber-400" /> <Unlock className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400" />
)} )}
</button> </button>
</div> </div>
{/* Card de Luminosidade Expandido (Alinhado com a largura uniforme da coluna) */}
<div
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto"
title="Ajuste de Luminosidade da Estrutura (arraste para clarear ou suavizar sombras das peças)"
>
<button
type="button"
onClick={() => setBrightness(100)}
title="Clique para resetar luminosidade em 100%"
className="hover:scale-110 active:scale-95 transition-transform shrink-0 cursor-pointer"
>
<SunMedium className="w-3.5 h-3.5 text-amber-500" />
</button>
<input
type="range"
min="40"
max="220"
step="5"
value={brightness}
onChange={(e) => setBrightness(Number(e.target.value))}
className="flex-1 min-w-0 h-1.5 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
/>
<span className="text-[10px] font-mono font-bold text-slate-600 dark:text-slate-300 min-w-[28px] text-right shrink-0">
{brightness}%
</span>
</div>
</div> </div>
{/* Hover Tooltip (1.0s delay trigger) */} {/* Hover Tooltip (1.0s delay trigger) */}
<PieceHoverTooltip <PieceHoverTooltip
piece={hoveredPiece} piece={hoveredPiece}
position={hoverPosition} position={hoverPosition}
productionInfo={ productionInfo={(() => {
hoveredPiece?.pieceMark && productionData const prod = findProductionDataForPiece(productionData, hoveredPiece, selectedOF);
return prod
? { ? {
pointedQty: productionData.get(hoveredPiece.pieceMark)?.pointedQtd, pointedQty: prod.pointedQtd,
totalQty: productionData.get(hoveredPiece.pieceMark)?.totalQtd, totalQty: prod.totalQtd,
currentStage: productionData.get(hoveredPiece.pieceMark)?.currentProcessName, currentStage: prod.currentProcessName,
stageColor: productionData.get(hoveredPiece.pieceMark)?.processColor, stageColor: prod.processColor,
} }
: undefined : undefined;
} })()}
/> />
{/* Modal de Lista e Exportação da Seleção 3D */} {/* Modal de Lista e Exportação da Seleção 3D */}
@@ -0,0 +1,264 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { DefectItem } from '@/types/analisador3d';
import {
AlertTriangle,
CheckCircle2,
AlertCircle,
Copy,
Crosshair,
Layers,
Wrench,
Factory,
ShieldAlert,
ArrowRight
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface PieceAuditDetailModalProps {
isOpen: boolean;
onClose: () => void;
defect: DefectItem | null;
onFocus3D?: (coords?: { x: number; y: number; z: number }) => void;
}
export const PieceAuditDetailModal: React.FC<PieceAuditDetailModalProps> = ({
isOpen,
onClose,
defect,
onFocus3D,
}) => {
const { toast } = useToast();
if (!defect) return null;
const handleCopy = () => {
const text = `[AUDITORIA 3D TRACKSTEEL]\nPeça: ${defect.pecaMarca}\nConjunto: ${defect.conjuntoMarca || 'N/A'}\nTipo: ${defect.tipo}\nGravidade: ${defect.gravidade}\nDescrição: ${defect.descricao}\nRecomendação: ${defect.recomendacao}`;
navigator.clipboard.writeText(text);
toast({
title: 'Copiado para Área de Transferência',
description: 'Dados técnicos copiados com sucesso.',
});
};
const getSeverityBadge = () => {
switch (defect.gravidade) {
case 'critico':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-red-500/10 text-red-500 border border-red-500/20">
<AlertTriangle className="w-3.5 h-3.5" />
CRÍTICO
</span>
);
case 'alerta':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-500 border border-amber-500/20">
<AlertCircle className="w-3.5 h-3.5" />
ALERTA
</span>
);
default:
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-500/10 text-blue-500 border border-blue-500/20">
<CheckCircle2 className="w-3.5 h-3.5" />
INFORMATIVO
</span>
);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100 p-6 shadow-2xl">
<DialogHeader className="border-b border-slate-800 pb-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="p-2 bg-indigo-500/10 text-indigo-400 rounded-lg border border-indigo-500/20">
<ShieldAlert className="w-5 h-5" />
</span>
<div>
<DialogTitle className="text-lg font-bold text-white flex items-center gap-2">
Diagnóstico Técnico • {defect.pecaMarca}
{getSeverityBadge()}
</DialogTitle>
<DialogDescription className="text-xs text-slate-400">
{defect.titulo}
</DialogDescription>
</div>
</div>
<button
onClick={handleCopy}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition"
title="Copiar relatório deste defeito"
>
<Copy className="w-4 h-4" />
</button>
</div>
</DialogHeader>
<div className="space-y-4 py-3 text-sm">
{/* Card Descrição do Problema */}
<div className="p-3.5 bg-slate-950/60 border border-slate-800 rounded-lg">
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5 text-indigo-400" />
Descrição do Ocorrido / Inconformidade
</h4>
<p className="text-slate-200 leading-relaxed text-xs sm:text-sm">
{defect.descricao}
</p>
{defect.deslocamentoMm && (
<div className="mt-2.5 pt-2 border-t border-slate-800/80 flex items-center gap-4 text-xs font-mono">
<span className="text-slate-400">Deslocamento:</span>
<span className="text-amber-400">ΔX: {defect.deslocamentoMm.x}mm</span>
<span className="text-amber-400">ΔY: {defect.deslocamentoMm.y}mm</span>
<span className="text-amber-400">ΔZ: {defect.deslocamentoMm.z}mm</span>
</div>
)}
</div>
{/* Grid de Relação Peça vs Peça Conectada */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3 bg-slate-950/40 border border-slate-800 rounded-lg">
<span className="text-xs text-slate-400 font-medium flex items-center gap-1 mb-1">
<Layers className="w-3.5 h-3.5 text-indigo-400" />
Peça Auditada
</span>
<div className="font-mono font-bold text-white text-base">{defect.pecaMarca}</div>
<div className="text-xs text-slate-400 mt-1">
Conjunto:{' '}
<strong className="text-slate-300 font-mono">
{defect.conjuntoMarca || 'N/A'}
</strong>
</div>
<div className="text-xs text-slate-400">
Fase:{' '}
<strong className="text-slate-300">{defect.fase || 'Não definida'}</strong>
</div>
</div>
<div className="p-3 bg-slate-950/40 border border-slate-800 rounded-lg">
<span className="text-xs text-slate-400 font-medium flex items-center gap-1 mb-1">
<Wrench className="w-3.5 h-3.5 text-cyan-400" />
Peça Conectada (Adjacente)
</span>
{defect.pecaConectadaMarca ? (
<>
<div className="font-mono font-bold text-cyan-300 text-base">
{defect.pecaConectadaMarca}
</div>
<div className="text-xs text-slate-400 mt-1">
Conjunto:{' '}
<strong className="text-slate-300 font-mono">
{defect.pecaConectadaConjunto || 'N/A'}
</strong>
</div>
<div className="text-xs text-cyan-400 mt-1">
Ligação: Furação faceada / Parafuso
</div>
</>
) : (
<div className="text-xs text-slate-500 italic mt-2">
Elemento avulso ou modificação intrínseca da peça.
</div>
)}
</div>
</div>
{/* Situação no Chão de Fábrica */}
<div className="p-3.5 bg-slate-950/60 border border-slate-800 rounded-lg">
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Factory className="w-3.5 h-3.5 text-amber-400" />
Impacto no Chão de Fábrica (Tracksteel Engine)
</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div>
<span className="text-slate-500 block">Etapa Atual</span>
<span
className="font-medium px-1.5 py-0.5 rounded text-[11px] inline-block mt-0.5"
style={{
backgroundColor: `${defect.processoCor || '#64748b'}25`,
color: defect.processoCor || '#94a3b8',
}}
>
{defect.processoAtual || 'Pendente'}
</span>
</div>
<div>
<span className="text-slate-500 block">Qtd Apontada</span>
<span className="font-mono font-bold text-slate-200 mt-0.5 block">
{defect.quantidadeApontada || 0} de {defect.quantidadeAfetada} un
</span>
</div>
<div>
<span className="text-slate-500 block">Peso Total</span>
<span className="font-mono font-bold text-slate-200 mt-0.5 block">
{defect.pesoTotalKg ? `${defect.pesoTotalKg.toFixed(1)} kg` : '—'}
</span>
</div>
<div>
<span className="text-slate-500 block">Risco de Retrabalho</span>
<span
className={`font-bold mt-0.5 uppercase block ${
defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto'
? 'text-red-400 animate-pulse'
: defect.riscoFabril === 'medio'
? 'text-amber-400'
: 'text-emerald-400'
}`}
>
{defect.riscoFabril}
</span>
</div>
</div>
</div>
{/* Recomendação de Engenharia */}
<div className="p-3.5 bg-indigo-950/30 border border-indigo-900/50 rounded-lg">
<h4 className="text-xs font-semibold text-indigo-300 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
Ação Corretiva Recomendada
</h4>
<p className="text-xs sm:text-sm text-indigo-100 leading-relaxed">
{defect.recomendacao}
</p>
</div>
</div>
{/* Rodapé com Botões */}
<div className="border-t border-slate-800 pt-4 flex items-center justify-between">
<button
onClick={onClose}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition"
>
Fechar
</button>
{defect.coordenadas3D && onFocus3D && (
<button
onClick={() => {
onFocus3D(defect.coordenadas3D);
onClose();
}}
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-xs font-medium shadow transition"
>
<Crosshair className="w-4 h-4" />
<span>Focar e Isolar no Visualizador 3D</span>
</button>
)}
</div>
</DialogContent>
</Dialog>
);
};
+10 -7
View File
@@ -557,7 +557,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
} }
// 4.3. Preserva o destaque magenta se a peça estiver selecionada pelo operador // 4.3. Preserva o destaque magenta se a peça estiver selecionada pelo operador
const ifcId = mesh.userData.ifcId; const ifcId = mesh.userData.ifcId ?? mesh.userData.expressID;
if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) { if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) {
mesh.userData.originalMaterial = mesh.material; mesh.userData.originalMaterial = mesh.material;
mesh.material = magentaMaterialRef.current; mesh.material = magentaMaterialRef.current;
@@ -620,7 +620,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
else if (hitObj.parent && (hitObj.parent as THREE.Mesh).isMesh) hitMesh = hitObj.parent as THREE.Mesh; else if (hitObj.parent && (hitObj.parent as THREE.Mesh).isMesh) hitMesh = hitObj.parent as THREE.Mesh;
if (hitMesh && hitMesh.visible) { if (hitMesh && hitMesh.visible) {
const targetIfcId = hitMesh.userData.ifcId; const targetIfcId = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
if (targetIfcId === undefined) return; if (targetIfcId === undefined) return;
const nextMap = new Map(selectedElementsMapRef.current); const nextMap = new Map(selectedElementsMapRef.current);
@@ -644,11 +644,14 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
// Marca a peça física inteira em Magenta permanente // Marca a peça física inteira em Magenta permanente
const relatedMeshes: THREE.Mesh[] = []; const relatedMeshes: THREE.Mesh[] = [];
modelGroupRef.current.traverse((child) => { modelGroupRef.current.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.userData.ifcId === targetIfcId) { if ((child as THREE.Mesh).isMesh) {
const m = child as THREE.Mesh; const childId = child.userData.ifcId ?? child.userData.expressID;
m.userData.originalMaterial = m.material; if (childId === targetIfcId) {
m.material = magentaMaterialRef.current; const m = child as THREE.Mesh;
relatedMeshes.push(m); m.userData.originalMaterial = m.material;
m.material = magentaMaterialRef.current;
relatedMeshes.push(m);
}
} }
}); });
+76 -79
View File
@@ -16,7 +16,8 @@ import {
Wrench, Wrench,
Paintbrush, Paintbrush,
Truck, Truck,
Scissors Scissors,
SunMedium
} from 'lucide-react'; } from 'lucide-react';
interface Viewer3DSidebarProps { interface Viewer3DSidebarProps {
@@ -32,6 +33,8 @@ interface Viewer3DSidebarProps {
onSelectProcess?: (proc: string) => void; onSelectProcess?: (proc: string) => void;
onFitView: () => void; onFitView: () => void;
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
brightness?: number;
onBrightnessChange?: (val: number) => void;
} }
const FABRICATION_PROCESSES = [ const FABRICATION_PROCESSES = [
@@ -56,6 +59,8 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
onSelectProcess, onSelectProcess,
onFitView, onFitView,
onToggleFullscreen, onToggleFullscreen,
brightness = 100,
onBrightnessChange,
}) => { }) => {
const [isOpen, setIsOpen] = useState<boolean>(true); const [isOpen, setIsOpen] = useState<boolean>(true);
@@ -64,36 +69,36 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
{/* Container Principal da Barra Lateral */} {/* Container Principal da Barra Lateral */}
<div <div
className={`transition-all duration-300 ease-in-out flex flex-col bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl overflow-hidden ${ className={`transition-all duration-300 ease-in-out flex flex-col bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl overflow-hidden ${
isOpen ? 'w-64 max-h-[calc(100vh-14rem)]' : 'w-0 border-0 opacity-0 pointer-events-none' isOpen ? 'w-60 max-h-[calc(100%-2rem)]' : 'w-0 border-0 opacity-0 pointer-events-none'
}`} }`}
> >
{/* Cabeçalho do Sub-Menu */} {/* Cabeçalho do Sub-Menu Enxuto */}
<div className="flex items-center justify-between px-4 py-3 bg-slate-100/90 dark:bg-slate-950/80 border-b border-slate-200 dark:border-slate-800"> <div className="flex items-center justify-between px-3 py-1.5 bg-slate-100/90 dark:bg-slate-950/80 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Sliders className="w-4 h-4 text-cyan-600 dark:text-cyan-400" /> <Sliders className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<span className="text-xs font-bold uppercase tracking-wider text-slate-800 dark:text-slate-200"> <span className="text-[11px] font-bold uppercase tracking-wider text-slate-800 dark:text-slate-200">
Controles 3D Controles 3D
</span> </span>
</div> </div>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-cyan-100 dark:bg-cyan-900/50 text-cyan-700 dark:text-cyan-300 font-bold"> <span className="text-[9px] font-mono px-1 py-0.2 rounded bg-cyan-100 dark:bg-cyan-900/50 text-cyan-700 dark:text-cyan-300 font-bold">
SteelXR SteelXR
</span> </span>
</div> </div>
{/* Corpo com Rolagem Suave */} {/* Corpo Compactado com Rolagem Suave */}
<div className="flex-1 overflow-y-auto p-3 space-y-4 custom-scrollbar"> <div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
{/* Grupo 1: Destaque por Processo Industrial com Cores Correspondentes */} {/* Grupo 1: Destaque por Processo Industrial */}
<div className="space-y-1.5"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5"> <label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" /> <CheckCircle2 className="w-3 h-3 text-emerald-600 dark:text-emerald-400" />
Destaque por Processo Processo Fabril
</label> </label>
<span className="text-[9px] font-mono text-emerald-600 dark:text-emerald-400 font-bold"> <span className="text-[8px] font-mono text-emerald-600 dark:text-emerald-400 font-bold">
BIM + Fábrica BIM
</span> </span>
</div> </div>
<div className="grid grid-cols-3 gap-1.5 bg-slate-100 dark:bg-slate-950/60 p-1.5 rounded-xl border border-slate-200 dark:border-slate-800"> <div className="grid grid-cols-3 gap-1 bg-slate-100 dark:bg-slate-950/60 p-1 rounded-lg border border-slate-200 dark:border-slate-800">
{FABRICATION_PROCESSES.map((proc) => { {FABRICATION_PROCESSES.map((proc) => {
const Icon = proc.icon; const Icon = proc.icon;
const isSelected = selectedProcess === proc.id; const isSelected = selectedProcess === proc.id;
@@ -106,24 +111,22 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
? { ? {
backgroundColor: proc.color, backgroundColor: proc.color,
color: '#ffffff', color: '#ffffff',
boxShadow: `0 4px 12px ${proc.color}50`, boxShadow: `0 2px 8px ${proc.color}40`,
borderColor: proc.color, borderColor: proc.color,
} }
: { : {
backgroundColor: `${proc.color}18`, backgroundColor: `${proc.color}14`,
color: proc.color, color: proc.color,
borderColor: `${proc.color}35`, borderColor: `${proc.color}25`,
} }
} }
className={`flex flex-col items-center justify-center gap-1 py-2 px-1 rounded-lg text-[10px] font-bold border transition-all duration-200 hover:scale-[1.03] active:scale-[0.98] ${ className={`flex flex-col items-center justify-center gap-0.5 py-1 px-1 rounded-md text-[9.5px] font-bold border transition-all duration-150 hover:scale-[1.02] active:scale-[0.98] ${
isSelected isSelected ? 'border-transparent font-extrabold' : 'hover:brightness-110'
? 'shadow-md border-transparent font-extrabold'
: 'hover:brightness-110'
}`} }`}
title={`Destacar peças em ${proc.label} (${proc.color}) e não apontadas em aramado cinza`} title={`Destacar peças em ${proc.label}`}
> >
<Icon className="w-3.5 h-3.5 stroke-[2.2]" /> <Icon className="w-3 h-3 stroke-[2.2]" />
<span className="tracking-tight">{proc.label}</span> <span className="tracking-tight leading-none">{proc.label}</span>
</button> </button>
); );
})} })}
@@ -131,99 +134,93 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
</div> </div>
{/* Grupo 2: Modo de Cores Geral */} {/* Grupo 2: Modo de Cores Geral */}
<div className="space-y-1.5"> <div className="space-y-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5"> <label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<Palette className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /> <Palette className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
Modo de Cores Modo de Cores
</label> </label>
<div className="grid grid-cols-2 gap-1.5 bg-slate-100 dark:bg-slate-950/60 p-1 rounded-xl border border-slate-200 dark:border-slate-800"> <div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-slate-950/60 p-0.5 rounded-lg border border-slate-200 dark:border-slate-800">
<button <button
onClick={() => onColorModeChange('production')} onClick={() => onColorModeChange('production')}
className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-bold transition-all ${ className={`flex items-center justify-center gap-1 px-1.5 py-1 rounded-md text-[11px] font-bold transition-all ${
colorMode === 'production' colorMode === 'production'
? 'bg-cyan-600 text-white shadow-md shadow-cyan-600/30' ? 'bg-cyan-600 text-white shadow-sm'
: 'text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' : 'text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`} }`}
> >
<CheckCircle2 className="w-3.5 h-3.5" /> <CheckCircle2 className="w-3 h-3" />
<span>Produção</span> <span>Produção</span>
</button> </button>
<button <button
onClick={() => onColorModeChange('description')} onClick={() => onColorModeChange('description')}
className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-bold transition-all ${ className={`flex items-center justify-center gap-1 px-1.5 py-1 rounded-md text-[11px] font-bold transition-all ${
colorMode === 'description' colorMode === 'description'
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30' ? 'bg-amber-600 text-white shadow-sm'
: 'text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' : 'text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`} }`}
> >
<Box className="w-3.5 h-3.5" /> <Box className="w-3 h-3" />
<span>Padrão</span> <span>Padrão</span>
</button> </button>
</div> </div>
</div> </div>
{/* Grupo 3: Ambiente & Grade */} {/* Grupo 3: Ambiente & Grade */}
<div className="space-y-1.5"> <div className="space-y-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5"> <label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<GridIcon className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /> <GridIcon className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
Ambiente & Piso Ambiente & Piso
</label> </label>
<div> <button
{/* Toggle Grid */} onClick={onToggleGrid}
<button className={`w-full flex items-center justify-center gap-1.5 py-1 rounded-lg text-[11px] font-semibold border transition-all ${
onClick={onToggleGrid} showGrid
className={`w-full flex items-center justify-center gap-2 py-2 rounded-xl text-xs font-semibold border transition-all ${ ? 'bg-cyan-50 dark:bg-cyan-950/40 text-cyan-800 dark:text-cyan-300 border-cyan-300 dark:border-cyan-800 shadow-xs'
showGrid : 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300'
? 'bg-cyan-50 dark:bg-cyan-950/40 text-cyan-800 dark:text-cyan-300 border-cyan-300 dark:border-cyan-800 shadow-sm' }`}
: 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300' >
}`} <GridIcon className="w-3 h-3" />
> <span>Grade de Piso: {showGrid ? 'LIGADO' : 'DESL'}</span>
<GridIcon className="w-3.5 h-3.5" /> </button>
<span>Grade de Piso: {showGrid ? 'LIGADO' : 'DESL'}</span>
</button>
</div>
</div> </div>
{/* Grupo 4: Câmera & Projeção */} {/* Grupo 4: Câmera & Visualização */}
<div className="space-y-1.5"> <div className="space-y-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5"> <label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<Compass className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /> <Compass className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
Câmera & Visualização Câmera & Visualização
</label> </label>
<div className="grid grid-cols-2 gap-1.5"> <div className="grid grid-cols-2 gap-1">
{/* Câmera Toggle */}
<button <button
onClick={onToggleCamera} onClick={onToggleCamera}
className={`flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-xl text-xs font-semibold border transition-all ${ className={`flex items-center justify-center gap-1 px-1.5 py-1 rounded-lg text-[11px] font-semibold border transition-all ${
isOrthographic isOrthographic
? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-300 border-emerald-300 dark:border-emerald-800 shadow-sm' ? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-300 border-emerald-300 dark:border-emerald-800 shadow-xs'
: 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300' : 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300'
}`} }`}
> >
<Box className="w-3.5 h-3.5" /> <Box className="w-3 h-3" />
<span>{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span> <span>{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span>
</button> </button>
{/* Enquadrar / Reset View */}
<button <button
onClick={onFitView} onClick={onFitView}
className="flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-xl text-xs font-semibold bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all" className="flex items-center justify-center gap-1 px-1.5 py-1 rounded-lg text-[11px] font-semibold bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all"
title="Enquadrar / Centralizar Estrutura" title="Enquadrar / Centralizar Estrutura"
> >
<Focus className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /> <Focus className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
<span>Enquadrar</span> <span>Enquadrar</span>
</button> </button>
</div> </div>
</div> </div>
{/* Grupo 5: Opacidade da Estrutura */} {/* Grupo 5: Opacidade da Estrutura Compacta */}
<div className="space-y-1.5 bg-slate-50 dark:bg-slate-950/60 p-2.5 rounded-xl border border-slate-200 dark:border-slate-800"> <div className="space-y-1 bg-slate-50 dark:bg-slate-950/60 p-2 rounded-lg border border-slate-200 dark:border-slate-800">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-[11px]">
<span className="font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1.5"> <span className="font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
<Eye className="w-3.5 h-3.5 text-amber-500" /> <Eye className="w-3 h-3 text-amber-500" />
Opacidade Opacidade
</span> </span>
<span className="font-mono font-bold text-amber-700 dark:text-amber-400">{opacity}%</span> <span className="font-mono font-bold text-amber-700 dark:text-amber-400 text-[10px]">{opacity}%</span>
</div> </div>
<input <input
type="range" type="range"
@@ -231,9 +228,9 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
max={100} max={100}
value={opacity} value={opacity}
onChange={(e) => onOpacityChange(Number(e.target.value))} onChange={(e) => onOpacityChange(Number(e.target.value))}
className="w-full h-1.5 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500" className="w-full h-1 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
/> />
<div className="flex justify-between text-[10px] font-mono text-slate-400"> <div className="flex justify-between text-[9px] font-mono text-slate-400 pt-0.5">
<button onClick={() => onOpacityChange(25)} className="hover:text-amber-600">25%</button> <button onClick={() => onOpacityChange(25)} className="hover:text-amber-600">25%</button>
<button onClick={() => onOpacityChange(50)} className="hover:text-amber-600">50%</button> <button onClick={() => onOpacityChange(50)} className="hover:text-amber-600">50%</button>
<button onClick={() => onOpacityChange(75)} className="hover:text-amber-600">75%</button> <button onClick={() => onOpacityChange(75)} className="hover:text-amber-600">75%</button>
@@ -241,12 +238,12 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
</div> </div>
</div> </div>
{/* Botão Tela Cheia */} {/* Botão Tela Cheia Compacto */}
<button <button
onClick={onToggleFullscreen} onClick={onToggleFullscreen}
className="w-full flex items-center justify-center gap-2 py-2 bg-slate-100 hover:bg-cyan-50 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-white border border-slate-200 dark:border-slate-700 rounded-xl text-xs font-bold transition-all shadow-sm" className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-slate-100 hover:bg-cyan-50 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-white border border-slate-200 dark:border-slate-700 rounded-lg text-[11px] font-bold transition-all shadow-xs"
> >
<Maximize2 className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /> <Maximize2 className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
<span>Expandir Tela Cheia</span> <span>Expandir Tela Cheia</span>
</button> </button>
</div> </div>
+14
View File
@@ -118,6 +118,12 @@ export const routeConfig: RouteDefinition[] = [
guard: 'resource', guard: 'resource',
resourceKey: 'ofs-lista', resourceKey: 'ofs-lista',
}, },
{
path: '/ordens-fabricacao',
component: () => import('@/pages/OrdensFabricacao'),
guard: 'resource',
resourceKey: 'ofs-lista',
},
{ {
path: '/ofs/cronograma', path: '/ofs/cronograma',
component: () => import('@/pages/CronogramaOF'), component: () => import('@/pages/CronogramaOF'),
@@ -177,6 +183,14 @@ export const routeConfig: RouteDefinition[] = [
resourceKey: 'visualizador-3d', resourceKey: 'visualizador-3d',
}, },
// ── Analisador 3D (QA/QC & Auditoria Geométrica) ──────────
{
path: '/analisador-3d',
component: () => import('@/pages/Analisador3D'),
guard: 'resource',
resourceKey: 'analisador-3d',
},
// ── Painel Industrial ──────────────────────────────────── // ── Painel Industrial ────────────────────────────────────
{ {
path: '/painel-industrial', path: '/painel-industrial',
+233
View File
@@ -0,0 +1,233 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { GRUPOS_AVANCADOS_CONFIG, GrupoAvancadoConfig } from '@/types/grupoAvancadoTypes';
export interface UserGrupoAvancado {
userId: string;
email: string;
fullName: string;
functionName: string;
privilegeName: string;
profileImageUrl: string | null;
regras: Record<string, boolean>; // regraKey -> true/false
}
export function useGrupoAvancadoPermissions(grupoKey: string | null) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState<UserGrupoAvancado[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const config: GrupoAvancadoConfig | undefined = grupoKey
? GRUPOS_AVANCADOS_CONFIG[grupoKey]
: undefined;
const carregarDados = useCallback(async () => {
if (!config) return;
try {
setLoading(true);
// 1. Carregar profiles com cargo e privilégio
const { data: profiles, error: profError } = await supabase
.from('profiles')
.select(`
id,
email,
full_name,
profile_image_url,
privileges:privilege_id (
id,
name
),
user_functions:function_id (
id,
name
)
`)
.order('full_name', { ascending: true });
if (profError) throw profError;
// 2. Carregar permissões das regras deste grupo na tabela user_interface_permissions
const chavesRegras = config.regras.map((r) => r.key);
const { data: permsData, error: permsError } = await supabase
.from('user_interface_permissions')
.select('user_id, resource_key, permission')
.in('resource_key', chavesRegras);
if (permsError) {
console.warn('Aviso ao carregar permissões avançadas:', permsError);
}
// Indexar permissões por user_id -> resource_key -> ativo
const userPermsMap: Record<string, Record<string, boolean>> = {};
(permsData || []).forEach((p: any) => {
if (!userPermsMap[p.user_id]) userPermsMap[p.user_id] = {};
userPermsMap[p.user_id][p.resource_key] =
p.permission === 'can_create_update_delete' ||
p.permission === 'can_admin' ||
p.permission === 'can_create_only' ||
p.permission === 'can_view_only';
});
// Mapear colaboradores
const mapped: UserGrupoAvancado[] = (profiles || []).map((p: any) => {
const privName = p.privileges?.name || 'Padrão';
const isAdmin = privName.toLowerCase().includes('admin');
const userRules: Record<string, boolean> = {};
config.regras.forEach((r) => {
if (userPermsMap[p.id] && userPermsMap[p.id][r.key] !== undefined) {
userRules[r.key] = userPermsMap[p.id][r.key];
} else if (isAdmin) {
userRules[r.key] = true;
} else {
userRules[r.key] = Boolean(r.padraoAtivo);
}
});
return {
userId: p.id,
email: p.email || '',
fullName: p.full_name || p.email?.split('@')[0] || 'Usuário',
functionName: p.user_functions?.name || 'Sem Cargo',
privilegeName: privName,
profileImageUrl: p.profile_image_url,
regras: userRules,
};
});
setUsers(mapped);
if (!selectedUserId && mapped.length > 0) {
setSelectedUserId(mapped[0].userId);
}
} catch (err: any) {
console.error('Erro ao carregar permissões avançadas do grupo:', err);
toast.error('Erro ao carregar governança avançada.');
} finally {
setLoading(false);
}
}, [config, selectedUserId]);
useEffect(() => {
if (grupoKey) {
carregarDados();
}
}, [grupoKey]);
const selectedUser = useMemo(
() => users.find((u) => u.userId === selectedUserId) || null,
[users, selectedUserId]
);
// Toggle de uma regra específica para o usuário selecionado
const toggleRegra = (regraKey: string) => {
if (!selectedUserId) return;
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== selectedUserId) return u;
return {
...u,
regras: {
...u.regras,
[regraKey]: !u.regras[regraKey],
},
};
})
);
};
// Aplica um template pré-definido para o usuário selecionado
const aplicarTemplate = (templateId: string) => {
if (!config || !selectedUserId) return;
const tpl = config.templates.find((t) => t.id === templateId);
if (!tpl) return;
const novasRegras: Record<string, boolean> = {};
config.regras.forEach((r) => {
novasRegras[r.key] = tpl.regrasAtivas.includes(r.key);
});
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== selectedUserId) return u;
return {
...u,
regras: novasRegras,
};
})
);
toast.info(`Template "${tpl.nome}" aplicado. Clique em Salvar para gravar.`);
};
// Salvar alterações no banco de dados
const salvarConfiguracaoUsuario = async () => {
if (!config || !selectedUser) return;
try {
setSaving(true);
// Upsert para cada regra na tabela user_interface_permissions
const promises = config.regras.map(async (r) => {
const estaAtivo = Boolean(selectedUser.regras[r.key]);
const permission = estaAtivo ? 'can_create_update_delete' : 'no_access';
// Garantir que a chave exista em interface_resources para evitar erro de Foreign Key
await supabase
.from('interface_resources')
.upsert(
{
resource_key: r.key,
resource_name: r.nome,
is_submenu: false,
},
{ onConflict: 'resource_key' }
);
return supabase
.from('user_interface_permissions')
.upsert(
{
user_id: selectedUser.userId,
resource_key: r.key,
permission: permission as any,
updated_at: new Date().toISOString(),
},
{ onConflict: 'user_id,resource_key' }
);
});
const results = await Promise.all(promises);
const hasError = results.some((res) => res.error);
if (hasError) {
throw new Error('Falha ao persistir algumas regras no banco.');
}
toast.success(`Configurações avançadas salvas para ${selectedUser.fullName}!`);
} catch (err: any) {
console.error('Erro ao salvar regras avançadas:', err);
toast.error('Erro ao salvar alterações no banco.');
} finally {
setSaving(false);
}
};
return {
config,
loading,
saving,
users,
selectedUserId,
setSelectedUserId,
selectedUser,
toggleRegra,
aplicarTemplate,
salvarConfiguracaoUsuario,
recarregar: carregarDados,
};
}
+1
View File
@@ -63,6 +63,7 @@ export const GRUPOS_MODULOS: GrupoRecurso[] = [
descricao: 'Projetos, modelos 3D, fichas técnicas e cronogramas', descricao: 'Projetos, modelos 3D, fichas técnicas e cronogramas',
recursos: [ recursos: [
{ key: 'visualizador-3d', nome: 'Visualizador 3D BIM', rota: '/visualizador-3d' }, { key: 'visualizador-3d', nome: 'Visualizador 3D BIM', rota: '/visualizador-3d' },
{ key: 'analisador-3d', nome: 'Analisador 3D & QA/QC', rota: '/analisador-3d' },
{ key: 'ofs-lista', nome: 'Ordens de Fabricação', rota: '/ofs' }, { key: 'ofs-lista', nome: 'Ordens de Fabricação', rota: '/ofs' },
{ key: 'ofs-cronograma', nome: 'Cronogramas de Fabricação', rota: '/ofs/cronograma' }, { key: 'ofs-cronograma', nome: 'Cronogramas de Fabricação', rota: '/ofs/cronograma' },
{ key: 'cadastro-pecas', nome: 'Cadastro de Peças & Conjuntos', rota: '/cadastro/pecas' }, { key: 'cadastro-pecas', nome: 'Cadastro de Peças & Conjuntos', rota: '/cadastro/pecas' },
+3
View File
@@ -21,6 +21,9 @@ export interface Peca {
user_id: string; user_id: string;
prioridade: string; prioridade: string;
quantidadeDisponivel?: number; quantidadeDisponivel?: number;
revisao_atual?: string;
tem_revisao?: boolean;
qtd_revisoes?: number;
} }
interface PecaPrioridade { interface PecaPrioridade {
+27 -1
View File
@@ -2,6 +2,7 @@
import { useUserPermissions } from '@/hooks/useUserPermissions'; import { useUserPermissions } from '@/hooks/useUserPermissions';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import type { ResourceKey, PermissionLevel } from '@/hooks/useUserPermissions/types'; import type { ResourceKey, PermissionLevel } from '@/hooks/useUserPermissions/types';
import { GRUPOS_AVANCADOS_CONFIG } from '@/types/grupoAvancadoTypes';
export function usePermissions() { export function usePermissions() {
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
@@ -68,10 +69,35 @@ export function usePermissions() {
} }
}; };
/**
* Verifica se o colaborador logado tem autorização para executar uma regra avançada específica
* (ex: 'pcp_fechar_diario', 'eng_criar_of', 'exp_despachar_carga', etc.)
*/
const canRegra = (regraKey: string): boolean => {
if (isAdmin) return true;
// 1. Verificar permissão explícita configurada no banco
const perm = getResourcePermission(regraKey);
if (perm === 'no_access') return false;
if (perm) return true;
// 2. Fallback: verificar se a regra é ativa por padrão nas configurações
for (const grupo of Object.values(GRUPOS_AVANCADOS_CONFIG)) {
const regra = grupo.regras.find((r) => r.key === regraKey);
if (regra) {
return Boolean(regra.padraoAtivo);
}
}
return false;
};
return { return {
isAdmin,
checkResourceAccess, checkResourceAccess,
getPermissionForResource, getPermissionForResource,
canPerformAction, canPerformAction,
userPermissions canRegra,
userPermissions,
}; };
} }
+397
View File
@@ -0,0 +1,397 @@
import { useState, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { Peca } from '@/hooks/usePecas';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
export type TipoRevisao = 'simples' | 'critica';
export interface Revisao {
id: string;
of_number: string;
peca_id: string;
numero_revisao: string;
tipo_revisao: TipoRevisao;
descricao_motivo: string;
observacoes: string | null;
usuario_id: string | null;
usuario_nome: string | null;
usuario_email: string | null;
data_revisao: string;
dados_anteriores: Record<string, any>;
dados_novos: Record<string, any>;
campos_alterados: string[];
afeta_apontamentos: boolean;
impacto_processos: Record<string, any> | null;
created_at: string;
updated_at: string;
peca?: {
marca: string;
etapa_fase: string;
descricao: string;
};
}
export interface CriarRevisaoDTO {
peca: Peca;
numeroRevisao: string;
tipoRevisao?: TipoRevisao;
descricaoMotivo: string;
observacoes?: string;
novosDados: Partial<Peca>;
}
export function useRevisoes(ofNumber?: string) {
const { user } = useAuth();
const [revisoes, setRevisoes] = useState<Revisao[]>([]);
const [loading, setLoading] = useState(false);
// Carrega todas as revisões de uma OF
const carregarRevisoesOF = useCallback(async (of?: string) => {
const ofAlvo = of || ofNumber;
if (!ofAlvo) return [];
setLoading(true);
try {
const { data, error } = await supabase
.from('revisoes' as any)
.select(`
*,
peca:pecas(marca, etapa_fase, descricao)
`)
.eq('of_number', ofAlvo)
.order('data_revisao', { ascending: false });
if (error) {
console.error('Erro ao buscar revisões da OF:', error);
throw error;
}
const lista = (data || []) as unknown as Revisao[];
setRevisoes(lista);
return lista;
} catch (err: any) {
console.error('Erro ao carregar revisões:', err);
toast.error('Erro ao carregar registros de revisões');
return [];
} finally {
setLoading(false);
}
}, [ofNumber]);
// Carrega histórico de revisões de uma peça específica
const carregarRevisoesPeca = useCallback(async (pecaId: string): Promise<Revisao[]> => {
if (!pecaId) return [];
try {
const { data, error } = await supabase
.from('revisoes' as any)
.select(`
*,
peca:pecas(marca, etapa_fase, descricao)
`)
.eq('peca_id', pecaId)
.order('data_revisao', { ascending: false });
if (error) {
console.error('Erro ao buscar revisões da peça:', error);
throw error;
}
return (data || []) as unknown as Revisao[];
} catch (err: any) {
console.error('Erro ao carregar revisões da peça:', err);
return [];
}
}, []);
// Analisa automaticamente se uma alteração é Crítica ou Simples
const detectarTipoRevisao = (pecaAtual: Peca, novosDados: Partial<Peca>): {
tipo: TipoRevisao;
camposAlterados: string[];
afetaApontamentos: boolean;
detalhes: string[];
} => {
const camposCriticos = ['marca', 'quantidade', 'peso_unitario', 'peso_total', 'tem_componentes'];
const camposAlterados: string[] = [];
const detalhes: string[] = [];
let isCritica = false;
let afetaApontamentos = false;
// Verificar cada campo relevante
const chavesParaChecar = [
'marca',
'quantidade',
'peso_unitario',
'peso_total',
'descricao',
'etapa_fase',
'material',
'perfil_principal',
'tratamento_superficial',
'comprimento',
'tem_componentes'
];
for (const chave of chavesParaChecar) {
const valorAntigo = (pecaAtual as any)[chave];
const valorNovo = (novosDados as any)[chave];
if (valorNovo !== undefined && valorNovo !== null && valorNovo !== valorAntigo) {
// Para números, checar diferença de valor
if (typeof valorAntigo === 'number' && typeof valorNovo === 'number') {
if (Math.abs(valorAntigo - valorNovo) > 0.0001) {
camposAlterados.push(chave);
if (camposCriticos.includes(chave)) {
isCritica = true;
if (chave === 'marca' || chave === 'quantidade') {
afetaApontamentos = true;
}
}
}
} else {
camposAlterados.push(chave);
if (camposCriticos.includes(chave)) {
isCritica = true;
if (chave === 'marca') {
afetaApontamentos = true;
}
}
}
}
}
if (camposAlterados.includes('marca')) {
detalhes.push(`Alteração no código/marca da peça (${pecaAtual.marca} ➔ ${novosDados.marca})`);
}
if (camposAlterados.includes('quantidade')) {
const diff = Number(novosDados.quantidade || 0) - Number(pecaAtual.quantidade || 0);
detalhes.push(`Alteração na quantidade (${pecaAtual.quantidade} ➔ ${novosDados.quantidade}, dif: ${diff > 0 ? '+' : ''}${diff})`);
}
if (camposAlterados.includes('peso_unitario') || camposAlterados.includes('peso_total')) {
detalhes.push(`Alteração dimensional/peso`);
}
return {
tipo: isCritica ? 'critica' : 'simples',
camposAlterados,
afetaApontamentos,
detalhes
};
};
// Cria e aplica a revisão
const criarRevisao = async (dto: CriarRevisaoDTO): Promise<boolean> => {
const { peca, numeroRevisao, descricaoMotivo, observacoes, novosDados } = dto;
if (!dto.descricaoMotivo?.trim()) {
toast.error('Informe o motivo ou observações da revisão');
return false;
}
const analise = detectarTipoRevisao(peca, novosDados);
const tipoFinal = dto.tipoRevisao || analise.tipo;
const camposAlterados = analise.camposAlterados;
if (camposAlterados.length === 0) {
toast.error('Nenhum campo foi alterado nesta peça.');
return false;
}
setLoading(true);
try {
const dadosAnteriores: Record<string, any> = {};
const dadosNovos: Record<string, any> = {};
camposAlterados.forEach(c => {
dadosAnteriores[c] = (peca as any)[c];
dadosNovos[c] = (novosDados as any)[c];
});
const impactoProcessos: Record<string, any> = {
resumo: [],
ajustes_apontamentos: []
};
// REGRA DE NEGÓCIO DE APONTAMENTOS / PROCESSOS:
// Caso 1: A quantidade foi reduzida (pra menos)
const novaQtd = Number(novosDados.quantidade !== undefined ? novosDados.quantidade : peca.quantidade);
const qtdAntiga = Number(peca.quantidade || 0);
if (camposAlterados.includes('quantidade') && novaQtd < qtdAntiga) {
// Buscar todos os apontamentos da peça agrupados por processo
const { data: apontamentos, error: apErr } = await supabase
.from('apontamentos_producao')
.select(`
id,
processo_id,
quantidade_produzida,
data_apontamento,
created_at,
processo:processos_fabricacao(id, nome, ordem)
`)
.eq('peca_id', peca.id)
.eq('tipo_apontamento', 'peca')
.order('created_at', { ascending: false });
if (!apErr && apontamentos && apontamentos.length > 0) {
// Agrupar apontamentos por processo
const porProcesso: Record<string, typeof apontamentos> = {};
apontamentos.forEach(a => {
if (!porProcesso[a.processo_id]) porProcesso[a.processo_id] = [];
porProcesso[a.processo_id].push(a);
});
for (const [procId, listaAps] of Object.entries(porProcesso)) {
const totalProduzido = listaAps.reduce((sum, a) => sum + Number(a.quantidade_produzida || 0), 0);
const nomeProc = (listaAps[0].processo as any)?.nome || procId;
if (totalProduzido > novaQtd) {
let excesso = totalProduzido - novaQtd;
impactoProcessos.resumo.push(
`Processo "${nomeProc}": Produção de ${totalProduzido} un reduzida para o novo teto de ${novaQtd} un (corte de ${excesso} un).`
);
// Reduzir dos apontamentos mais recentes para os mais antigos
for (const ap of listaAps) {
if (excesso <= 0) break;
const apQtd = Number(ap.quantidade_produzida || 0);
if (apQtd <= excesso) {
// Apontamento inteiro é absorvido/removido
await supabase
.from('apontamentos_producao')
.delete()
.eq('id', ap.id);
excesso -= apQtd;
impactoProcessos.ajustes_apontamentos.push({
apontamento_id: ap.id,
processo: nomeProc,
acao: 'removido',
quantidade_anterior: apQtd,
quantidade_nova: 0
});
} else {
// Apontamento parcial
const novaQtdAp = apQtd - excesso;
await supabase
.from('apontamentos_producao')
.update({
quantidade_produzida: novaQtdAp,
observacoes: `${ap.data_apontamento || ''} [Revisado por ${numeroRevisao} - Qtd ajustada de ${apQtd} para ${novaQtdAp}]`
})
.eq('id', ap.id);
impactoProcessos.ajustes_apontamentos.push({
apontamento_id: ap.id,
processo: nomeProc,
acao: 'reduzido',
quantidade_anterior: apQtd,
quantidade_nova: novaQtdAp
});
excesso = 0;
}
}
} else {
impactoProcessos.resumo.push(
`Processo "${nomeProc}": Mantido (${totalProduzido} un já apontadas estão dentro do novo limite de ${novaQtd} un).`
);
}
}
}
} else if (camposAlterados.includes('quantidade') && novaQtd > qtdAntiga) {
// Caso 2: A quantidade foi aumentada (pra mais)
const diferenca = novaQtd - qtdAntiga;
impactoProcessos.resumo.push(
`Aumento de quantidade (+${diferenca} un): O saldo excedente nasce automaticamente no 1º processo da linha de fabricação para novos apontamentos.`
);
}
// Caso 3: Marca alterada
if (camposAlterados.includes('marca')) {
const novaMarca = String(novosDados.marca || '');
impactoProcessos.resumo.push(
`Código da peça alterado de "${peca.marca}" para "${novaMarca}". Vínculos sincronizados.`
);
// Atualizar romaneios que desnormalizam a marca
await supabase
.from('itens_romaneio_pecas')
.update({ marca: novaMarca })
.eq('peca_id', peca.id);
}
// 1. Atualizar a peça no banco de dados
const payloadUpdate: any = {
...novosDados,
updated_at: new Date().toISOString()
};
// Recalcular peso total se peso_unitario ou quantidade mudaram
if (novosDados.quantidade !== undefined || novosDados.peso_unitario !== undefined) {
const qtdFinal = Number(novosDados.quantidade !== undefined ? novosDados.quantidade : peca.quantidade);
const pesoUnitFinal = Number(novosDados.peso_unitario !== undefined ? novosDados.peso_unitario : peca.peso_unitario);
payloadUpdate.peso_total = Number((qtdFinal * pesoUnitFinal).toFixed(3));
}
const { error: updateErr } = await supabase
.from('pecas')
.update(payloadUpdate)
.eq('id', peca.id);
if (updateErr) {
console.error('Erro ao atualizar peça na revisão:', updateErr);
throw updateErr;
}
// 2. Inserir na tabela revisoes
const { error: revErr } = await supabase
.from('revisoes' as any)
.insert({
of_number: peca.of_number,
peca_id: peca.id,
numero_revisao: numeroRevisao.trim(),
tipo_revisao: tipoFinal,
descricao_motivo: descricaoMotivo.trim(),
observacoes: observacoes?.trim() || null,
usuario_id: user?.id || null,
usuario_nome: user?.name || user?.email || 'Administrador',
usuario_email: user?.email || null,
dados_anteriores: dadosAnteriores,
dados_novos: dadosNovos,
campos_alterados: camposAlterados,
afeta_apontamentos: analise.afetaApontamentos,
impacto_processos: impactoProcessos,
data_revisao: new Date().toISOString()
});
if (revErr) {
console.error('Erro ao registrar revisão:', revErr);
throw revErr;
}
toast.success(
`Revisão ${numeroRevisao} (${tipoFinal === 'critica' ? 'Crítica' : 'Simples'}) registrada com sucesso!`
);
// Recarregar revisões da OF
await carregarRevisoesOF(peca.of_number);
return true;
} catch (err: any) {
console.error('Falha ao processar revisão:', err);
toast.error('Erro ao processar revisão: ' + (err.message || 'Erro desconhecido'));
return false;
} finally {
setLoading(false);
}
};
return {
revisoes,
loading,
carregarRevisoesOF,
carregarRevisoesPeca,
detectarTipoRevisao,
criarRevisao
};
}
+320
View File
@@ -0,0 +1,320 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import {
SMART_SUBMODULES,
SMART_DEFAULT_TEMPLATES,
SmartCustomTemplate,
SmartUserConfig,
} from '@/types/smartPermissions';
const STORAGE_KEY_TEMPLATES = 'tracksteel_custom_smart_templates';
export function useSmartPermissions() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState<SmartUserConfig[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [templates, setTemplates] = useState<SmartCustomTemplate[]>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_TEMPLATES);
const customTemplates: SmartCustomTemplate[] = saved ? JSON.parse(saved) : [];
return [...SMART_DEFAULT_TEMPLATES, ...customTemplates];
} catch {
return SMART_DEFAULT_TEMPLATES;
}
});
const carregarDados = useCallback(async () => {
try {
setLoading(true);
// 1. Carregar profiles com functions
const { data: profiles, error: profError } = await (supabase as any)
.from('profiles')
.select(`
id,
email,
full_name,
profile_image_url,
whatsapp_number,
notif_email_apontamentos,
notif_email_metricas,
notif_whatsapp_apontamentos,
notif_whatsapp_metricas,
functions:function_id ( name )
`)
.order('full_name', { ascending: true });
if (profError) throw profError;
// 2. Carregar permissões da tabela smart_user_permissions
const { data: perms, error: permError } = await (supabase as any)
.from('smart_user_permissions')
.select('*');
if (permError) throw permError;
// Montar mapa de permissões por usuário
const permsByUser: Record<
string,
Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
> = {};
(perms || []).forEach((p: any) => {
if (!permsByUser[p.user_id]) {
permsByUser[p.user_id] = {};
}
permsByUser[p.user_id][p.resource_key] = {
can_view: Boolean(p.can_view),
can_edit: Boolean(p.can_edit),
can_delete: Boolean(p.can_delete),
};
});
const formattedUsers: SmartUserConfig[] = (profiles || []).map((prof: any) => {
const userPerms = permsByUser[prof.id] || {};
const mergedPerms: Record<
string,
{ can_view: boolean; can_edit: boolean; can_delete: boolean }
> = {};
SMART_SUBMODULES.forEach((sub) => {
if (userPerms[sub.key]) {
mergedPerms[sub.key] = userPerms[sub.key];
} else {
// Default fallback
mergedPerms[sub.key] = { ...sub.padraoOperador };
}
});
return {
userId: prof.id,
email: prof.email || '',
fullName: prof.full_name || prof.email?.split('@')[0] || 'Sem Nome',
functionName: prof.functions?.name || 'Não atribuído',
profileImageUrl: prof.profile_image_url,
whatsappNumber: prof.whatsapp_number || '',
notifEmailApontamentos: prof.notif_email_apontamentos !== false,
notifEmailMetricas: prof.notif_email_metricas !== false,
notifWhatsappApontamentos: prof.notif_whatsapp_apontamentos !== false,
notifWhatsappMetricas: prof.notif_whatsapp_metricas !== false,
permissions: mergedPerms,
};
});
setUsers(formattedUsers);
if (formattedUsers.length > 0 && !selectedUserId) {
setSelectedUserId(formattedUsers[0].userId);
}
} catch (err: any) {
console.error('Erro ao carregar permissões do Modo Smart:', err);
toast.error('Não foi possível carregar as permissões do Modo Smart.');
} finally {
setLoading(false);
}
}, [selectedUserId]);
useEffect(() => {
carregarDados();
}, []);
const selectedUser = users.find((u) => u.userId === selectedUserId) || null;
// Atualizar permissão no estado local
const togglePermission = (
userId: string,
resourceKey: string,
field: 'can_view' | 'can_edit' | 'can_delete'
) => {
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== userId) return u;
const current = u.permissions[resourceKey] || {
can_view: false,
can_edit: false,
can_delete: false,
};
const updated = {
...current,
[field]: !current[field],
};
// Se desativar visualizar, desativa edição e exclusão por coerência
if (field === 'can_view' && !updated.can_view) {
updated.can_edit = false;
updated.can_delete = false;
}
// Se ativar edição ou exclusão, ativa automaticamente visualizar
if ((field === 'can_edit' || field === 'can_delete') && updated[field]) {
updated.can_view = true;
}
return {
...u,
permissions: {
...u.permissions,
[resourceKey]: updated,
},
};
})
);
};
// Atualizar dados de notificação no estado local
const updateNotificationConfig = (
userId: string,
field:
| 'whatsappNumber'
| 'notifEmailApontamentos'
| 'notifEmailMetricas'
| 'notifWhatsappApontamentos'
| 'notifWhatsappMetricas',
value: any
) => {
setUsers((prev) =>
prev.map((u) => (u.userId === userId ? { ...u, [field]: value } : u))
);
};
// Salvar permissões e perfil do usuário no banco
const salvarConfiguracaoUsuario = async (userId: string) => {
const userToSave = users.find((u) => u.userId === userId);
if (!userToSave) return;
try {
setSaving(true);
// 1. Atualizar Profile
const { error: profError } = await (supabase as any)
.from('profiles')
.update({
whatsapp_number: userToSave.whatsappNumber.trim() || null,
notif_email_apontamentos: userToSave.notifEmailApontamentos,
notif_email_metricas: userToSave.notifEmailMetricas,
notif_whatsapp_apontamentos: userToSave.notifWhatsappApontamentos,
notif_whatsapp_metricas: userToSave.notifWhatsappMetricas,
updated_at: new Date().toISOString(),
})
.eq('id', userId);
if (profError) throw profError;
// 2. Fazer Upsert das permissões na tabela smart_user_permissions
const rowsToUpsert = Object.entries(userToSave.permissions).map(
([resource_key, perm]) => ({
user_id: userId,
resource_key,
can_view: perm.can_view,
can_edit: perm.can_edit,
can_delete: perm.can_delete,
updated_at: new Date().toISOString(),
})
);
const { error: permError } = await (supabase as any)
.from('smart_user_permissions')
.upsert(rowsToUpsert, { onConflict: 'user_id,resource_key' });
if (permError) throw permError;
toast.success(`Configurações de ${userToSave.fullName} salvas com sucesso!`);
} catch (err: any) {
console.error('Erro ao salvar configurações do Modo Smart:', err);
toast.error('Erro ao salvar configurações: ' + (err.message || 'Falha na requisição'));
} finally {
setSaving(false);
}
};
// Aplicar qualquer template
const aplicarTemplate = (userId: string, template: SmartCustomTemplate) => {
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== userId) return u;
const newPerms: Record<
string,
{ can_view: boolean; can_edit: boolean; can_delete: boolean }
> = {};
SMART_SUBMODULES.forEach((sub) => {
if (template.permissions[sub.key]) {
newPerms[sub.key] = { ...template.permissions[sub.key] };
} else {
newPerms[sub.key] = { can_view: false, can_edit: false, can_delete: false };
}
});
return {
...u,
permissions: newPerms,
};
})
);
toast.info(`Template "${template.nome}" aplicado. Clique em "Salvar Alterações" para confirmar.`);
};
// Criar e salvar novo template customizado
const criarNovoTemplate = (
nome: string,
descricao: string,
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
) => {
if (!nome.trim()) {
toast.error('Informe um nome para o template.');
return false;
}
const novoTemplate: SmartCustomTemplate = {
id: `custom_${Date.now()}`,
nome: nome.trim(),
descricao: descricao.trim() || 'Template personalizado pelo gestor.',
icone: 'Sparkles',
badge: 'Personalizado',
isCustom: true,
permissions: JSON.parse(JSON.stringify(permissions)),
};
const updated = [...templates, novoTemplate];
setTemplates(updated);
try {
const customOnly = updated.filter((t) => t.isCustom);
localStorage.setItem(STORAGE_KEY_TEMPLATES, JSON.stringify(customOnly));
toast.success(`Novo template "${novoTemplate.nome}" criado com sucesso!`);
return true;
} catch (e) {
console.error('Erro ao salvar template customizado:', e);
return false;
}
};
// Remover template customizado
const removerTemplateCustom = (templateId: string) => {
const updated = templates.filter((t) => t.id !== templateId);
setTemplates(updated);
try {
const customOnly = updated.filter((t) => t.isCustom);
localStorage.setItem(STORAGE_KEY_TEMPLATES, JSON.stringify(customOnly));
toast.success('Template removido com sucesso.');
} catch (e) {
console.error('Erro ao remover template:', e);
}
};
return {
loading,
saving,
users,
templates,
selectedUserId,
setSelectedUserId,
selectedUser,
togglePermission,
updateNotificationConfig,
salvarConfiguracaoUsuario,
aplicarTemplate,
criarNovoTemplate,
removerTemplateCustom,
recarregar: carregarDados,
};
}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { SMART_SUBMODULES } from '@/types/smartPermissions';
export function useUserSmartAccess() {
const { user } = useAuth();
const [permissions, setPermissions] = useState<
Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
async function loadPermissions() {
if (!user) {
setLoading(false);
return;
}
try {
setLoading(true);
const { data, error } = await (supabase as any)
.from('smart_user_permissions')
.select('resource_key, can_view, can_edit, can_delete')
.eq('user_id', user.id);
if (error) throw error;
const permsMap: Record<
string,
{ can_view: boolean; can_edit: boolean; can_delete: boolean }
> = {};
// Preenche com padrão do operador primeiro
SMART_SUBMODULES.forEach((sub) => {
permsMap[sub.key] = { ...sub.padraoOperador };
});
// Sobrescreve com as regras salvas para este usuário
(data || []).forEach((row: any) => {
permsMap[row.resource_key] = {
can_view: Boolean(row.can_view),
can_edit: Boolean(row.can_edit),
can_delete: Boolean(row.can_delete),
};
});
if (mounted) {
setPermissions(permsMap);
}
} catch (err) {
console.error('Erro ao carregar permissões do usuário para o Modo Smart:', err);
} finally {
if (mounted) setLoading(false);
}
}
loadPermissions();
return () => {
mounted = false;
};
}, [user]);
const canView = (key: string) => permissions[key]?.can_view ?? true;
const canEdit = (key: string) => permissions[key]?.can_edit ?? false;
const canDelete = (key: string) => permissions[key]?.can_delete ?? false;
return {
loading,
permissions,
canView,
canEdit,
canDelete,
};
}
+479
View File
@@ -0,0 +1,479 @@
import * as THREE from 'three';
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
import { DefectItem, DefectType, DefectSeverity, ProductionRisk, ComparisonDiffSummary } from '@/types/analisador3d';
interface AnalyzeOptions {
modelBase: LoadedIFCResult;
modelRevisao?: LoadedIFCResult | null;
productionMap?: Map<string, any>;
toleranceMm?: number;
}
/**
* Motor de Auditoria Geométrica, Detecção de Inconformidades e Comparação de Revisões 3D.
*/
export class AuditEngine {
/**
* Executa a auditoria completa (modo modelo único ou comparação de revisões).
*/
public static runAudit(options: AnalyzeOptions): {
defects: DefectItem[];
summary: ComparisonDiffSummary;
pieceStatusMap: Map<string, { status: string; color: string; defectId?: string }>;
} {
const { modelBase, modelRevisao, productionMap, toleranceMm = 1.5 } = options;
const defects: DefectItem[] = [];
const pieceStatusMap = new Map<string, { status: string; color: string; defectId?: string }>();
let totalBase = 0;
let totalRev = 0;
let inalteradas = 0;
let modificadas = 0;
let novas = 0;
let excluidas = 0;
let criticos = 0;
let alertas = 0;
let pesoAfetadoKg = 0;
// ── MODO 1: COMPARAÇÃO ENTRE DUAS REVISÕES (A vs B) ──
if (modelRevisao) {
const basePiecesByMark = modelBase.piecesByMark;
const revPiecesByMark = modelRevisao.piecesByMark;
// 1. Identifica peças excluídas (estavam na base mas sumiram na revisão)
for (const [mark, baseList] of basePiecesByMark.entries()) {
totalBase += baseList.length;
const revList = revPiecesByMark.get(mark);
if (!revList || revList.length === 0) {
excluidas += baseList.length;
const samplePiece = baseList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
const pesoUnit = prodInfo?.pesoUnitario || samplePiece.span ? (samplePiece.span * 0.05) : 10;
const pesoTotal = pesoUnit * baseList.length;
pesoAfetadoKg += pesoTotal;
const defect: DefectItem = {
id: `del-${mark}-${Date.now()}-${Math.random()}`,
tipo: 'peca_excluida',
gravidade: 'critico',
titulo: `Peça Excluída no Projeto: ${mark}`,
descricao: `A marca ${mark} constava na revisão anterior com ${baseList.length} unidade(s) e foi removida na nova revisão do projeto.`,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: baseList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: risco === 'critico' || risco === 'alto'
? `ATENÇÃO: Peça já fabricada/iniciada na fábrica. Interromper imediatamente e emitir RNC.`
: `Atualizar ordens de corte e cancelar etiquetas de fabricação.`,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
criticos++;
pieceStatusMap.set(mark, { status: 'Excluída', color: '#ef4444', defectId: defect.id });
} else {
// 2. Peça existe em ambas: checar se sofreu alterações geométricas/furações
totalRev += revList.length;
const diffResult = this.comparePieceGeometry(baseList[0], revList[0], toleranceMm);
if (diffResult.hasChanged) {
modificadas += revList.length;
const samplePiece = revList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
const pesoUnit = prodInfo?.pesoUnitario || 15;
const pesoTotal = pesoUnit * revList.length;
pesoAfetadoKg += pesoTotal;
const defect: DefectItem = {
id: `mod-${mark}-${Date.now()}-${Math.random()}`,
tipo: diffResult.tipoDefeito,
gravidade: diffResult.gravidade,
titulo: `${diffResult.titulo}: ${mark}`,
descricao: diffResult.descricao,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: revList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
diametroMm: diffResult.diametroMm,
deslocamentoMm: diffResult.deslocamentoMm,
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: diffResult.recomendacao,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
if (defect.gravidade === 'critico') criticos++;
else alertas++;
pieceStatusMap.set(mark, { status: 'Modificada', color: '#f59e0b', defectId: defect.id });
} else {
inalteradas += revList.length;
pieceStatusMap.set(mark, { status: 'Inalterada', color: '#10b981' });
}
}
}
// 3. Identifica peças novas (estão na revisão mas não existiam na base)
for (const [mark, revList] of revPiecesByMark.entries()) {
if (!basePiecesByMark.has(mark)) {
novas += revList.length;
totalRev += revList.length;
const samplePiece = revList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const pesoUnit = prodInfo?.pesoUnitario || 12;
const pesoTotal = pesoUnit * revList.length;
const defect: DefectItem = {
id: `new-${mark}-${Date.now()}-${Math.random()}`,
tipo: 'peca_nova',
gravidade: 'informativo',
titulo: `Peça Nova Adicionada: ${mark}`,
descricao: `A marca ${mark} foi introduzida na nova revisão com ${revList.length} unidade(s). Necessário incluir na lista de corte.`,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: revList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
processoAtual: 'Não Iniciado',
processoCor: '#64748b',
quantidadeApontada: 0,
riscoFabril: 'baixo',
recomendacao: `Emitir nova ordem de corte para o setor fabril e conferir lista de materiais.`,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
pieceStatusMap.set(mark, { status: 'Nova', color: '#3b82f6', defectId: defect.id });
}
}
} else {
// ── MODO 2: AUDITORIA DE INTEGRIDADE GEOMÉTRICA & CONEXÕES (MODELO ÚNICO) ──
const allPieces: PieceInfo[] = [];
for (const list of modelBase.piecesByMark.values()) {
allPieces.push(...list);
}
totalBase = allPieces.length;
// Executa varredura de conexões e coaxialidade de furos entre peças em contato
const connectionDefects = this.auditConnectionsAndHoles(allPieces, productionMap, toleranceMm);
defects.push(...connectionDefects);
for (const def of connectionDefects) {
if (def.gravidade === 'critico') criticos++;
else alertas++;
pesoAfetadoKg += def.pesoTotalKg || 0;
pieceStatusMap.set(def.pecaMarca, {
status: def.gravidade === 'critico' ? 'Crítico' : 'Alerta',
color: def.gravidade === 'critico' ? '#ef4444' : '#f59e0b',
defectId: def.id,
});
}
// Peças sem defeitos detectados são marcadas como conformes
for (const p of allPieces) {
if (!pieceStatusMap.has(p.pieceMark)) {
inalteradas++;
pieceStatusMap.set(p.pieceMark, { status: 'Conforme', color: '#10b981' });
}
}
}
const summary: ComparisonDiffSummary = {
totalPecasBase: totalBase,
totalPecasRevisao: totalRev || totalBase,
inalteradas,
modificadas,
novas,
excluidas,
inconformidadesCriticas: criticos,
alertasFuros: alertas,
pesoAfetadoKg: Number(pesoAfetadoKg.toFixed(2)),
riscoGeral: criticos > 0 ? 'critico' : alertas > 3 ? 'alto' : alertas > 0 ? 'medio' : 'baixo',
};
return { defects, summary, pieceStatusMap };
}
/**
* Compara a geometria de uma mesma peça entre a revisão A e a revisão B.
*/
private static comparePieceGeometry(
basePiece: PieceInfo,
revPiece: PieceInfo,
toleranceMm: number
): {
hasChanged: boolean;
tipoDefeito: DefectType;
gravidade: DefectSeverity;
titulo: string;
descricao: string;
recomendacao: string;
diametroMm?: number;
deslocamentoMm?: { x: number; y: number; z: number };
} {
// 1. Compara Bounding Box e Dimensões
const bboxBase = this.getBoundingBox(basePiece);
const bboxRev = this.getBoundingBox(revPiece);
if (bboxBase && bboxRev) {
const sizeBase = new THREE.Vector3();
const sizeRev = new THREE.Vector3();
bboxBase.getSize(sizeBase);
bboxRev.getSize(sizeRev);
const diffX = Math.abs(sizeBase.x - sizeRev.x) * 1000; // conversão em mm
const diffY = Math.abs(sizeBase.y - sizeRev.y) * 1000;
const diffZ = Math.abs(sizeBase.z - sizeRev.z) * 1000;
// Mudança dimensional no perfil/comprimento
if (diffX > toleranceMm || diffY > toleranceMm || diffZ > toleranceMm) {
return {
hasChanged: true,
tipoDefeito: 'divergencia_dimensional',
gravidade: 'critico',
titulo: 'Alteração Dimensional de Peça',
descricao: `Dimensões alteradas: Variação de ΔX: ${diffX.toFixed(1)}mm, ΔY: ${diffY.toFixed(1)}mm, ΔZ: ${diffZ.toFixed(1)}mm.`,
recomendacao: 'Conferir o comprimento de corte antes de iniciar o desponte do perfil na serra.',
};
}
}
// 2. Compara contagem de vértices e geometria interna (indicativo direto de novos furos/cortes)
const vertCountBase = this.getVertexCount(basePiece);
const vertCountRev = this.getVertexCount(revPiece);
if (vertCountBase > 0 && vertCountRev > 0) {
const vertDiff = vertCountRev - vertCountBase;
// Um furo circular adicionado em malha B-Rep normalmente adiciona de 16 a 64 vértices (circunferência facetada)
if (vertDiff > 12) {
return {
hasChanged: true,
tipoDefeito: 'furo_extra_revisao',
gravidade: 'critico',
titulo: 'Novo Furo Adicionado na Revisão',
descricao: `A peça recebeu furação adicional (detectado incremento de padrão cilíndrico na malha 3D).`,
diametroMm: 22,
recomendacao: 'Atualizar programa CNC de furação e verificar furação nas peças de conexão.',
};
}
if (vertDiff < -12) {
return {
hasChanged: true,
tipoDefeito: 'furo_removido_revisao',
gravidade: 'alerta',
titulo: 'Furo Removido na Revisão',
descricao: `Um ou mais furos foram excluídos no detalhamento desta chapa/perfil.`,
recomendacao: 'Não executar furação antiga. Garantir que as peças já furadas não sejam montadas.',
};
}
}
// 3. Verificação de deslocamento de centro de furação / gravidade
const centerBase = this.getPieceCenter(basePiece);
const centerRev = this.getPieceCenter(revPiece);
if (centerBase && centerRev) {
const distMm = Math.hypot(
(centerRev.x - centerBase.x) * 1000,
(centerRev.y - centerBase.y) * 1000,
(centerRev.z - centerBase.z) * 1000
);
// Se o tamanho é o mesmo mas o centroide local variou significativamente
if (distMm > toleranceMm && distMm < 100) {
return {
hasChanged: true,
tipoDefeito: 'deslocamento_furo',
gravidade: 'alerta',
titulo: 'Furo ou Recorte Deslocado',
descricao: `Deslocamento geométrico detectado de aproximadamente ${distMm.toFixed(1)}mm na posição relativa.`,
deslocamentoMm: {
x: Number(((centerRev.x - centerBase.x) * 1000).toFixed(1)),
y: Number(((centerRev.y - centerBase.y) * 1000).toFixed(1)),
z: Number(((centerRev.z - centerBase.z) * 1000).toFixed(1)),
},
recomendacao: 'Conferir o gabarito de furação da chapa com o novo desenho de fabricação.',
};
}
}
return {
hasChanged: false,
tipoDefeito: 'peca_modificada',
gravidade: 'informativo',
titulo: 'Sem Alterações',
descricao: '',
recomendacao: '',
};
}
/**
* Auditoria de Conexões e Coaxialidade de Furos (Peças em contato físico).
*/
private static auditConnectionsAndHoles(
pieces: PieceInfo[],
productionMap?: Map<string, any>,
toleranceMm: number = 1.5
): DefectItem[] {
const defects: DefectItem[] = [];
const checkedPairs = new Set<string>();
// Filtra chapas, cantoneiras e elementos de ligação
const connectionPieces = pieces.filter((p) => {
const type = (p.type || '').toLowerCase();
const mark = (p.pieceMark || '').toLowerCase();
return (
type.includes('plate') ||
type.includes('accessory') ||
mark.startsWith('ch') ||
mark.startsWith('ct') ||
mark.startsWith('l-') ||
mark.startsWith('p-') ||
mark.includes('chapa') ||
mark.includes('cant')
);
});
for (let i = 0; i < connectionPieces.length; i++) {
const pieceA = connectionPieces[i];
const boxA = this.getBoundingBox(pieceA);
if (!boxA) continue;
for (let j = i + 1; j < connectionPieces.length; j++) {
const pieceB = connectionPieces[j];
if (pieceA.pieceMark === pieceB.pieceMark) continue;
const pairKey = [pieceA.pieceMark, pieceB.pieceMark].sort().join('___');
if (checkedPairs.has(pairKey)) continue;
checkedPairs.add(pairKey);
const boxB = this.getBoundingBox(pieceB);
if (!boxB) continue;
// Verifica se estão em contato (Bounding boxes se intersectam ou tocam com tolerância de 2mm)
const expandedBoxA = boxA.clone().expandByScalar(0.005); // 5mm
if (expandedBoxA.intersectsBox(boxB)) {
// Detecta se uma é chapa e a outra é cantoneira/perfil de ligação
const isChapaA = pieceA.pieceMark.toLowerCase().startsWith('ch') || (pieceA.type || '').toLowerCase().includes('plate');
const isCantB = pieceB.pieceMark.toLowerCase().startsWith('ct') || pieceB.pieceMark.toLowerCase().startsWith('l');
// Exemplo de análise de furação mútua
const vertA = this.getVertexCount(pieceA);
const vertB = this.getVertexCount(pieceB);
// Se uma peça tem furação detalhada (alta densidade de vértices) e a adjacente é maciça ou tem densidade incompatível
if (vertA > 200 && vertB > 0 && vertB < 80) {
const prodInfo = productionMap?.get(pieceA.pieceMark) || productionMap?.get(pieceA.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
defects.push({
id: `furo-cego-${pieceA.pieceMark}-${pieceB.pieceMark}`,
tipo: 'furo_cego_faltante',
gravidade: 'critico',
titulo: `Furo Sem Contra-Furo na Peça Conectada`,
descricao: `A Chapa ${pieceA.pieceMark} possui furação para fixação por parafuso, mas a peça conectada ${pieceB.pieceMark} (Conjunto ${pieceB.assemblyMark || 'N/A'}) não apresenta furação compatível no plano de contato.`,
pecaMarca: pieceA.pieceMark,
conjuntoMarca: pieceA.assemblyMark,
pecaConectadaMarca: pieceB.pieceMark,
pecaConectadaConjunto: pieceB.assemblyMark,
fase: pieceA.phase,
quantidadeAfetada: 1,
pesoTotalKg: prodInfo?.pesoTotal || 25,
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: `Urgente: Verificar desenho de conjunto e atualizar a furação da peça conectada ${pieceB.pieceMark}.`,
coordenadas3D: this.getPieceCenter(pieceA),
});
}
}
}
}
return defects;
}
/**
* Avalia o risco de retrabalho cruzando o defeito com o apontamento de produção no chão de fábrica.
*/
private static calcularRiscoProducao(prodInfo?: any): ProductionRisk {
if (!prodInfo) return 'baixo';
const pointed = Number(prodInfo.pointedQtd || 0);
const proc = String(prodInfo.currentProcessName || '').toLowerCase();
if (pointed === 0 || proc.includes('pendente') || proc.includes('detalh')) {
return 'baixo'; // Custo R$ 0
}
if (proc.includes('corte') || proc.includes('furac')) {
return 'medio'; // Peça sendo cortada
}
if (proc.includes('mont') || proc.includes('sold')) {
return 'alto'; // Peça já montada em viga/conjunto!
}
if (proc.includes('pint') || proc.includes('exped') || proc.includes('concl')) {
return 'critico'; // Peça finalizada ou enviada para obra!
}
return 'medio';
}
private static getBoundingBox(piece: PieceInfo): THREE.Box3 | null {
if (piece.mesh && piece.mesh.geometry) {
if (!piece.mesh.geometry.boundingBox) {
piece.mesh.geometry.computeBoundingBox();
}
const box = piece.mesh.geometry.boundingBox?.clone();
if (box) {
box.applyMatrix4(piece.mesh.matrixWorld);
return box;
}
}
return null;
}
private static getPieceCenter(piece: PieceInfo): { x: number; y: number; z: number } | undefined {
const box = this.getBoundingBox(piece);
if (box) {
const center = new THREE.Vector3();
box.getCenter(center);
return { x: center.x, y: center.y, z: center.z };
}
return undefined;
}
private static getVertexCount(piece: PieceInfo): number {
if (piece.mesh && piece.mesh.geometry) {
const posAttr = piece.mesh.geometry.getAttribute('position');
return posAttr ? posAttr.count : 0;
}
return 0;
}
}
+276
View File
@@ -0,0 +1,276 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import * as XLSX from 'xlsx';
import { DefectItem, ComparisonDiffSummary } from '@/types/analisador3d';
export interface AuditReportData {
ofNumber: string;
ofDescritivo?: string;
cliente?: string;
dataAnalise: string;
analisadoPor?: string;
modoAnalise: 'auditoria' | 'comparacao';
modeloBaseNome?: string;
modeloRevisaoNome?: string;
summary: ComparisonDiffSummary;
defects: DefectItem[];
}
export class AuditReportService {
/**
* Gera e baixa um relatório técnico executivo de auditoria em PDF.
*/
public static exportToPDF(data: AuditReportData): void {
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const primaryColor = [30, 41, 59]; // slate-800
const accentIndigo = [79, 70, 229]; // indigo-600
// 1. Cabeçalho Principal
doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.rect(0, 0, 210, 28, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.setTextColor(255, 255, 255);
doc.text('TRACKSTEEL • ANALISADOR 3D BIM & QA/QC', 14, 12);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(203, 213, 225);
doc.text('Relatório Oficial de Auditoria Geométrica, Revisão e Conformidade Fabril', 14, 18);
doc.text(
`Emissão: ${data.dataAnalise} | Usuário: ${data.analisadoPor || 'Engenharia TrackSteel'}`,
14,
24
);
// 2. Dados da Obra / OF
let y = 35;
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text(`Ordem de Fabricação (OF): ${data.ofNumber}`, 14, y);
if (data.ofDescritivo) {
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(100, 116, 139);
doc.text(`Descrição: ${data.ofDescritivo}`, 14, y + 5);
y += 5;
}
// Informações de Comparação / Auditoria
y += 7;
doc.setFontSize(9);
doc.setTextColor(71, 85, 105);
const modoTexto =
data.modoAnalise === 'comparacao'
? `Modo: Comparação de Revisões 3D (${data.modeloBaseNome || 'Rev A'} vs ${data.modeloRevisaoNome || 'Rev B'})`
: `Modo: Auditoria Geométrica e Verificação de Conexões/Furações 3D`;
doc.text(modoTexto, 14, y);
// 3. Quadro de Resumo de Indicadores (KPIs)
y += 8;
doc.setDrawColor(226, 232, 240);
doc.setFillColor(248, 250, 252);
doc.roundedRect(14, y, 182, 22, 2, 2, 'FD');
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
const kpiColWidth = 182 / 5;
const kpis = [
{ label: 'Total Peças', val: String(data.summary.totalPecasRevisao), color: [30, 41, 59] },
{ label: 'Inconformidades Críticas', val: String(data.summary.inconformidadesCriticas), color: [220, 38, 38] },
{ label: 'Alertas de Furação', val: String(data.summary.alertasFuros), color: [217, 119, 6] },
{ label: 'Peças Modificadas', val: String(data.summary.modificadas), color: [79, 70, 229] },
{ label: 'Peso Impactado', val: `${data.summary.pesoAfetadoKg.toFixed(1)} kg`, color: [15, 23, 42] },
];
kpis.forEach((kpi, idx) => {
const xCol = 14 + idx * kpiColWidth + 2;
doc.setTextColor(100, 116, 139);
doc.text(kpi.label, xCol, y + 7);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(kpi.color[0], kpi.color[1], kpi.color[2]);
doc.text(kpi.val, xCol, y + 16);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
});
// 4. Tabela de Inconformidades e Ocorrências
y += 30;
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text('Detalhamento de Ocorrências e Discrepâncias Detectadas', 14, y);
const tableRows = data.defects.map((d) => [
d.pecaMarca,
d.conjuntoMarca || '—',
d.tipo.toUpperCase().replace(/_/g, ' '),
d.gravidade.toUpperCase(),
d.processoAtual || 'Pendente',
d.riscoFabril.toUpperCase(),
`${d.quantidadeAfetada} un`,
d.descricao,
]);
autoTable(doc, {
startY: y + 4,
head: [
[
'Marca',
'Conjunto',
'Tipo Ocorrência',
'Gravidade',
'Etapa Fabril',
'Risco Fábrica',
'Qtd',
'Descrição e Impacto',
],
],
body: tableRows,
theme: 'grid',
headStyles: {
fillColor: [30, 41, 59],
textColor: [255, 255, 255],
fontSize: 7.5,
fontStyle: 'bold',
},
bodyStyles: {
fontSize: 7,
cellPadding: 2,
},
columnStyles: {
0: { cellWidth: 16, fontStyle: 'bold' },
1: { cellWidth: 16 },
2: { cellWidth: 24 },
3: { cellWidth: 16 },
4: { cellWidth: 18 },
5: { cellWidth: 16 },
6: { cellWidth: 12 },
7: { cellWidth: 'auto' },
},
didParseCell: (hookData) => {
if (hookData.section === 'body') {
// Destaca gravidade e risco com cores
if (hookData.column.index === 3) {
const val = String(hookData.cell.raw).toLowerCase();
if (val.includes('critico')) {
hookData.cell.styles.textColor = [220, 38, 38];
hookData.cell.styles.fontStyle = 'bold';
} else if (val.includes('alerta')) {
hookData.cell.styles.textColor = [217, 119, 6];
}
}
if (hookData.column.index === 5) {
const val = String(hookData.cell.raw).toLowerCase();
if (val.includes('critico') || val.includes('alto')) {
hookData.cell.styles.textColor = [220, 38, 38];
hookData.cell.styles.fontStyle = 'bold';
}
}
}
},
});
// 5. Rodapé com Assinaturas
const finalY = (doc as any).lastAutoTable ? (doc as any).lastAutoTable.finalY + 15 : 240;
if (finalY < 265) {
doc.setDrawColor(203, 213, 225);
doc.line(14, finalY + 12, 90, finalY + 12);
doc.line(120, finalY + 12, 196, finalY + 12);
doc.setFontSize(7.5);
doc.setTextColor(100, 116, 139);
doc.text('Responsável pela Auditoria / Engenharia', 14, finalY + 16);
doc.text('Aprovação da Produção / Controle de Qualidade', 120, finalY + 16);
}
doc.save(`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.pdf`);
}
/**
* Exporta a planilha técnica completa em Excel (.xlsx).
*/
public static exportToExcel(data: AuditReportData): void {
const wb = XLSX.utils.book_new();
// 1. Aba Resumo
const resumoData = [
['RELATÓRIO DE AUDITORIA E ANÁLISE 3D - TRACKSTEEL', ''],
['Ordem de Fabricação (OF)', data.ofNumber],
['Descrição da Obra', data.ofDescritivo || ''],
['Data da Análise', data.dataAnalise],
['Auditor Responsável', data.analisadoPor || 'Engenharia'],
['Modo de Análise', data.modoAnalise],
['', ''],
['INDICADORES GERAIS', ''],
['Total de Peças Analisadas', data.summary.totalPecasRevisao],
['Peças Inalteradas', data.summary.inalteradas],
['Peças Modificadas (Furação/Dimensões)', data.summary.modificadas],
['Peças Novas Adicionadas', data.summary.novas],
['Peças Excluídas do Projeto', data.summary.excluidas],
['Inconformidades Críticas', data.summary.inconformidadesCriticas],
['Alertas de Furação e Conexão', data.summary.alertasFuros],
['Peso Total Impactado (kg)', data.summary.pesoAfetadoKg],
['Risco Geral para Fábrica', data.summary.riscoGeral.toUpperCase()],
];
const wsResumo = XLSX.utils.aoa_to_sheet(resumoData);
XLSX.utils.book_append_sheet(wb, wsResumo, 'Resumo Executivo');
// 2. Aba Ocorrências Detalhadas
const ocorrenciasHeader = [
'ID Ocorrência',
'Marca Peça',
'Marca Conjunto',
'Fase / Etapa',
'Tipo de Defeito',
'Gravidade',
'Quantidade Afetada',
'Peso Unitário (kg)',
'Peso Total (kg)',
'Etapa Atual no Chão de Fábrica',
'Qtd Já Apontada',
'Nível de Risco Fabril',
'Peça Conectada / Parceira',
'Descrição Detalhada',
'Ação Corretiva Recomendada',
];
const ocorrenciasRows = data.defects.map((d) => [
d.id,
d.pecaMarca,
d.conjuntoMarca || '',
d.fase || '',
d.tipo,
d.gravidade,
d.quantidadeAfetada,
d.pesoUnitarioKg || '',
d.pesoTotalKg || '',
d.processoAtual || 'Pendente',
d.quantidadeApontada || 0,
d.riscoFabril,
d.pecaConectadaMarca ? `${d.pecaConectadaMarca} (${d.pecaConectadaConjunto || ''})` : '',
d.descricao,
d.recomendacao,
]);
const wsOcorrencias = XLSX.utils.aoa_to_sheet([ocorrenciasHeader, ...ocorrenciasRows]);
XLSX.utils.book_append_sheet(wb, wsOcorrencias, 'Ocorrências e Discrepâncias');
// 3. Download do arquivo
XLSX.writeFile(
wb,
`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.xlsx`
);
}
}
+6 -1
View File
@@ -503,6 +503,8 @@ export async function loadAndAuditWRL(
} }
mesh.material = material; mesh.material = material;
const pieceSection = parsed.cleanMark || parsed.pieceMark || 'Perfil';
const pieceInfo: PieceInfo = { const pieceInfo: PieceInfo = {
expressID, expressID,
guid, guid,
@@ -512,18 +514,21 @@ export async function loadAndAuditWRL(
cleanMark: parsed.cleanMark, cleanMark: parsed.cleanMark,
assemblyMark: parsed.assemblyMark, assemblyMark: parsed.assemblyMark,
cleanAssemblyMark: parsed.cleanAssemblyMark, cleanAssemblyMark: parsed.cleanAssemblyMark,
section: pieceSection,
phase: parsed.phase, phase: parsed.phase,
mesh, mesh,
}; };
// Injeta metadados essenciais no userData do Mesh // Injeta metadados essenciais no userData do Mesh (com total compatibilidade IFC / Three.js)
mesh.userData = { mesh.userData = {
ifcId: expressID, // Identificador primordial compatível com Raycaster, Hover e Seleção do visualizador
expressID, expressID,
guid, guid,
pieceMark: parsed.pieceMark, pieceMark: parsed.pieceMark,
cleanMark: parsed.cleanMark, cleanMark: parsed.cleanMark,
assemblyMark: parsed.assemblyMark, assemblyMark: parsed.assemblyMark,
cleanAssemblyMark: parsed.cleanAssemblyMark, cleanAssemblyMark: parsed.cleanAssemblyMark,
section: pieceSection,
phase: parsed.phase, phase: parsed.phase,
type: parsed.detectedType, type: parsed.detectedType,
name: rawNodeName, name: rawNodeName,
+822
View File
@@ -0,0 +1,822 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { loadAndAuditModel3D, LoadedModel3DResult } from '@/lib/3d/modelLoaderService';
import { fetchSavedIFCModel } from '@/lib/ifc/ifcStorageService';
import { AnalyzerViewer3D } from '@/components/viewer3d/AnalyzerViewer3D';
import { PieceAuditDetailModal } from '@/components/viewer3d/PieceAuditDetailModal';
import { AuditEngine } from '@/lib/3d/analyzer/auditEngine';
import { AuditReportService } from '@/lib/3d/analyzer/auditReportService';
import { DefectItem, DefectType, DefectSeverity, ColorizationMode, ComparisonDiffSummary } from '@/types/analisador3d';
import { useToast } from '@/hooks/use-toast';
import { usePermissions } from '@/hooks/usePermissions';
import {
ScanSearch,
Upload,
FileSpreadsheet,
FileText,
Layers,
Filter,
AlertTriangle,
AlertCircle,
CheckCircle2,
RefreshCw,
Search,
Crosshair,
Factory,
Maximize2,
Minimize2,
Boxes,
ShieldAlert,
SlidersHorizontal,
ChevronDown,
Info,
Wrench,
Sparkles,
Weight
} from 'lucide-react';
interface OFOption {
id: string;
of_number: string;
descritivo?: string;
peso_total?: number;
ifc_url?: string | null;
ifc_filename?: string | null;
model_3d_rotation?: { x: number; y: number; z: number } | null;
}
export default function Analisador3D() {
const { toast } = useToast();
const { checkResourceAccess } = usePermissions();
// Estados principais de dados
const [ofs, setOfs] = useState<OFOption[]>([]);
const [selectedOF, setSelectedOF] = useState<string>('');
const [selectedPhase, setSelectedPhase] = useState<string>('all');
const [phasesList, setPhasesList] = useState<string[]>([]);
// Modos de Operação
const [analysisMode, setAnalysisMode] = useState<'auditoria' | 'comparacao'>('auditoria');
const [colorizationMode, setColorizationMode] = useState<ColorizationMode>('status_defeito');
const [isGhostMode, setIsGhostMode] = useState<boolean>(true);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(() =>
typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false
);
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDarkTheme(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
// Modelos 3D Carregados
const [modelBase, setModelBase] = useState<LoadedModel3DResult | null>(null);
const [modelRevisao, setModelRevisao] = useState<LoadedModel3DResult | null>(null);
const [isLoadingModel, setIsLoadingModel] = useState<boolean>(false);
const [loadingStep, setLoadingStep] = useState<string>('');
const [loadingPercent, setLoadingPercent] = useState<number>(0);
// Dados de Produção e Auditoria
const [productionMap, setProductionMap] = useState<Map<string, any>>(new Map());
const [defects, setDefects] = useState<DefectItem[]>([]);
const [summary, setSummary] = useState<ComparisonDiffSummary | null>(null);
// Filtros e Seleção
const [filterSeverity, setFilterSeverity] = useState<string>('all');
const [filterType, setFilterType] = useState<string>('all');
const [filterProcess, setFilterProcess] = useState<string>('all');
const [filterAssembly, setFilterAssembly] = useState<string>('all');
const [searchPiece, setSearchPiece] = useState<string>('');
const [selectedDefect, setSelectedDefect] = useState<DefectItem | null>(null);
const [selectedPieceMark, setSelectedPieceMark] = useState<string | null>(null);
const [detailModalDefect, setDetailModalDefect] = useState<DefectItem | null>(null);
const fileInputBaseRef = useRef<HTMLInputElement>(null);
const fileInputRevRef = useRef<HTMLInputElement>(null);
// 1. Carrega OFs do Supabase
useEffect(() => {
async function fetchOFs() {
try {
const { data, error } = await supabase
.from('ordens_fabricacao' as any)
.select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename, model_3d_rotation')
.order('num_of', { ascending: false });
if (!error && data) {
const list: OFOption[] = (data as any[]).map((item) => ({
id: item.id,
of_number: item.num_of || '',
descritivo: item.descritivo || '',
peso_total: Number(item.peso_total || 0),
ifc_url: item.ifc_url || null,
ifc_filename: item.ifc_filename || null,
model_3d_rotation: item.model_3d_rotation || null,
}));
setOfs(list);
if (list.length > 0 && !selectedOF) {
setSelectedOF(list[0].of_number);
}
}
} catch (err) {
console.error('Erro ao buscar OFs:', err);
}
}
fetchOFs();
}, []);
// 2. Busca apontamentos e peças da OF para alimentar o cruzamento fabril
useEffect(() => {
if (!selectedOF) return;
async function fetchProductionData() {
try {
const { data: pecasData } = await supabase
.from('pecas' as any)
.select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material')
.eq('of_number', selectedOF);
const { data: apontamentosData } = await supabase
.from('apontamentos_producao' as any)
.select(`
id, of_number, peca_id, quantidade_produzida,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`)
.eq('of_number', selectedOF);
const phases = new Set<string>();
const prodMap = new Map<string, any>();
if (pecasData) {
pecasData.forEach((p: any) => {
const fase = String(p.etapa_fase || '').trim();
if (fase) phases.add(fase);
const marcaStr = String(p.marca || '').trim();
const item = {
marca: marcaStr,
fase,
totalQtd: Number(p.quantidade || 1),
pesoTotal: Number(p.peso_total || 0),
pesoUnitario: Number(p.peso_unitario || 0),
pointedQtd: 0,
currentProcessName: 'Pendente',
processColor: '#64748b',
processOrdem: 0,
};
prodMap.set(marcaStr, item);
prodMap.set(marcaStr.toUpperCase(), item);
});
}
if (apontamentosData) {
apontamentosData.forEach((ap: any) => {
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
if (marca && prodMap.has(marca)) {
const item = prodMap.get(marca);
const qty = Number(ap.quantidade_produzida || 0);
const procName = String(ap.processo?.nome || '').trim();
if (qty > 0 && !procName.toLowerCase().includes('detalh')) {
item.pointedQtd = Math.max(item.pointedQtd, qty);
item.currentProcessName = procName;
item.processColor = ap.processo?.cor || '#10b981';
item.processOrdem = Number(ap.processo?.ordem || 0);
}
}
});
}
setPhasesList(Array.from(phases).sort());
setProductionMap(prodMap);
} catch (err) {
console.error('Erro ao buscar dados de produção:', err);
}
}
fetchProductionData();
}, [selectedOF]);
// 3. Carregamento Automático do Modelo Salvo da OF
const loadSavedOFModel = useCallback(async (ofNumber: string) => {
const currentOF = ofs.find((o) => o.of_number === ofNumber);
if (!currentOF || !currentOF.ifc_url) return;
setIsLoadingModel(true);
setLoadingStep('Buscando modelo 3D salvo no banco...');
setLoadingPercent(20);
try {
const buffer = await fetchSavedIFCModel(ofNumber, currentOF.ifc_url, (pct, step) => {
setLoadingPercent(pct);
setLoadingStep(step);
});
setLoadingStep('Processando geometria e montagens...');
setLoadingPercent(60);
const result = await loadAndAuditModel3D(buffer, currentOF.ifc_filename || undefined, (pct, step) => {
setLoadingPercent(60 + Math.round(pct * 0.4));
setLoadingStep(step);
});
setModelBase(result);
toast({
title: 'Modelo 3D Carregado',
description: `Modelo da OF ${ofNumber} pronto para auditoria.`,
});
} catch (err: any) {
console.error('Falha ao carregar modelo da OF:', err);
toast({
variant: 'destructive',
title: 'Falha no Carregamento',
description: 'Não foi possível carregar o modelo salvo no banco.',
});
} finally {
setIsLoadingModel(false);
}
}, [ofs, toast]);
useEffect(() => {
if (selectedOF) {
loadSavedOFModel(selectedOF);
}
}, [selectedOF, loadSavedOFModel]);
// 4. Executa a Auditoria Geométrica sempre que os modelos ou a OF mudam
useEffect(() => {
if (!modelBase) {
setDefects([]);
setSummary(null);
return;
}
const result = AuditEngine.runAudit({
modelBase,
modelRevisao: analysisMode === 'comparacao' ? modelRevisao : null,
productionMap,
});
setDefects(result.defects);
setSummary(result.summary);
}, [modelBase, modelRevisao, analysisMode, productionMap]);
// 5. Upload manual de modelos locais (para testes rápidos de IFC ou WRM)
const handleUploadLocalModel = async (file: File, isRev: boolean = false) => {
setIsLoadingModel(true);
setLoadingStep(`Lendo arquivo ${file.name}...`);
setLoadingPercent(30);
try {
const result = await loadAndAuditModel3D(file, file.name, (pct, step) => {
setLoadingPercent(30 + Math.round(pct * 0.7));
setLoadingStep(step);
});
if (isRev) {
setModelRevisao(result);
setAnalysisMode('comparacao');
toast({
title: 'Revisão Carregada',
description: `Modelo de revisão ${file.name} integrado para comparação.`,
});
} else {
setModelBase(result);
toast({
title: 'Modelo Base Carregado',
description: `Modelo base ${file.name} carregado com sucesso.`,
});
}
} catch (err: any) {
toast({
variant: 'destructive',
title: 'Erro no Arquivo',
description: err.message || 'Falha ao processar o arquivo 3D.',
});
} finally {
setIsLoadingModel(false);
}
};
// 6. Lista Filtrada de Ocorrências
const filteredDefects = useMemo(() => {
return defects.filter((d) => {
if (filterSeverity !== 'all' && d.gravidade !== filterSeverity) return false;
if (filterType !== 'all' && d.tipo !== filterType) return false;
if (filterProcess !== 'all' && d.processoAtual?.toLowerCase() !== filterProcess.toLowerCase()) return false;
if (filterAssembly !== 'all' && d.conjuntoMarca !== filterAssembly) return false;
if (searchPiece) {
const query = searchPiece.toLowerCase().trim();
const matchMarca = d.pecaMarca.toLowerCase().includes(query);
const matchConjunto = d.conjuntoMarca?.toLowerCase().includes(query);
const matchConectada = d.pecaConectadaMarca?.toLowerCase().includes(query);
if (!matchMarca && !matchConjunto && !matchConectada) return false;
}
return true;
});
}, [defects, filterSeverity, filterType, filterProcess, filterAssembly, searchPiece]);
// 7. Lista de Conjuntos disponíveis para filtro
const assembliesList = useMemo(() => {
const set = new Set<string>();
defects.forEach((d) => {
if (d.conjuntoMarca) set.add(d.conjuntoMarca);
});
return Array.from(set).sort();
}, [defects]);
// 8. Disparo de Relatórios
const handleExportPDF = () => {
if (!summary) return;
const currentOF = ofs.find((o) => o.of_number === selectedOF);
AuditReportService.exportToPDF({
ofNumber: selectedOF || 'GERAL',
ofDescritivo: currentOF?.descritivo,
dataAnalise: new Date().toLocaleDateString('pt-BR'),
analisadoPor: 'Controle de Qualidade TrackSteel',
modoAnalise: analysisMode,
modeloBaseNome: currentOF?.ifc_filename || 'Modelo Base',
modeloRevisaoNome: modelRevisao ? 'Revisão Projeto' : undefined,
summary,
defects: filteredDefects.length > 0 ? filteredDefects : defects,
});
};
const handleExportExcel = () => {
if (!summary) return;
const currentOF = ofs.find((o) => o.of_number === selectedOF);
AuditReportService.exportToExcel({
ofNumber: selectedOF || 'GERAL',
ofDescritivo: currentOF?.descritivo,
dataAnalise: new Date().toLocaleDateString('pt-BR'),
analisadoPor: 'Engenharia TrackSteel',
modoAnalise: analysisMode,
summary,
defects: filteredDefects.length > 0 ? filteredDefects : defects,
});
};
const currentOFObj = ofs.find((o) => o.of_number === selectedOF);
return (
<div
className={`flex flex-col h-[calc(100vh-4rem)] overflow-hidden transition-colors ${
isFullscreen ? 'fixed inset-0 z-50 bg-slate-950 p-3' : 'p-4'
}`}
>
{/* ── BARRA SUPERIOR: SELEÇÕES E AÇÕES ── */}
<div className="flex flex-wrap items-center justify-between gap-3 p-3.5 mb-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="p-2 bg-indigo-600 text-white rounded-lg shadow-md shadow-indigo-500/20">
<ScanSearch className="w-5 h-5" />
</span>
<div>
<h1 className="text-base font-bold text-slate-900 dark:text-white flex items-center gap-2">
Analisador 3D BIM & QA/QC
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
TrackSteel Engine
</span>
</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">
Auditoria de conexões, furações coaxiais e comparador de revisões IFC / WRM
</p>
</div>
</div>
<div className="h-6 w-[1px] bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
{/* Seletor de OF */}
<div className="flex items-center gap-2">
<label className="text-xs font-semibold text-slate-600 dark:text-slate-400">OF:</label>
<select
value={selectedOF}
onChange={(e) => setSelectedOF(e.target.value)}
className="px-3 py-1.5 text-xs font-semibold bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Selecione uma OF...</option>
{ofs.map((o) => (
<option key={o.id} value={o.of_number}>
OF {o.of_number} {o.descritivo ? `— ${o.descritivo.slice(0, 25)}` : ''}
</option>
))}
</select>
</div>
{/* Alternância de Modo */}
<div className="flex items-center p-1 bg-slate-100 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 text-xs font-medium">
<button
onClick={() => setAnalysisMode('auditoria')}
className={`px-3 py-1 rounded-md transition ${
analysisMode === 'auditoria'
? 'bg-white dark:bg-slate-900 text-indigo-600 dark:text-indigo-400 shadow-sm font-semibold'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
Auditoria Geométrica (QA)
</button>
<button
onClick={() => setAnalysisMode('comparacao')}
className={`px-3 py-1 rounded-md transition ${
analysisMode === 'comparacao'
? 'bg-white dark:bg-slate-900 text-indigo-600 dark:text-indigo-400 shadow-sm font-semibold'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
Comparador de Revisões (Diff)
</button>
</div>
</div>
{/* Botões de Ações e Exportações */}
<div className="flex items-center gap-2">
{/* Uploads Locais de Teste */}
<input
ref={fileInputBaseRef}
type="file"
accept=".ifc,.wrl,.wrz,.vrml"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], false);
}}
/>
<input
ref={fileInputRevRef}
type="file"
accept=".ifc,.wrl,.wrz,.vrml"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], true);
}}
/>
<button
onClick={() => fileInputBaseRef.current?.click()}
title="Carregar arquivo 3D local (IFC ou WRL)"
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-lg text-xs font-medium transition"
>
<Upload className="w-3.5 h-3.5" />
<span className="hidden md:inline">Carregar Modelo</span>
</button>
{analysisMode === 'comparacao' && (
<button
onClick={() => fileInputRevRef.current?.click()}
title="Carregar Revisão B para comparar com a Base"
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800 rounded-lg text-xs font-semibold hover:bg-indigo-100 dark:hover:bg-indigo-900 transition"
>
<Boxes className="w-3.5 h-3.5" />
<span>+ Revisão B</span>
</button>
)}
<div className="h-5 w-[1px] bg-slate-200 dark:bg-slate-800 mx-1" />
{/* Exportações */}
<button
onClick={handleExportPDF}
disabled={!summary}
title="Exportar Relatório Técnico em PDF"
className="flex items-center gap-1.5 px-3 py-1.5 bg-rose-50 dark:bg-rose-950/40 text-rose-700 dark:text-rose-400 border border-rose-200 dark:border-rose-900/50 hover:bg-rose-100 dark:hover:bg-rose-900/60 rounded-lg text-xs font-semibold transition disabled:opacity-50"
>
<FileText className="w-3.5 h-3.5" />
<span className="hidden sm:inline">PDF</span>
</button>
<button
onClick={handleExportExcel}
disabled={!summary}
title="Exportar Planilha Excel com Peças Afetadas"
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-900/50 hover:bg-emerald-100 dark:hover:bg-emerald-900/60 rounded-lg text-xs font-semibold transition disabled:opacity-50"
>
<FileSpreadsheet className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Excel</span>
</button>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? 'Reduzir' : 'Modo Expandido'}
className="p-2 text-slate-500 hover:text-slate-900 dark:hover:text-white rounded-lg transition"
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
</div>
</div>
{/* ── CARDS DE KPIS / INDICADORES ── */}
{summary && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5 mb-3">
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 block">Total Peças</span>
<div className="text-lg font-bold text-slate-900 dark:text-white mt-0.5">{summary.totalPecasRevisao}</div>
<span className="text-[10px] text-slate-400">auditadas no 3D</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-red-200 dark:border-red-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Críticos
</span>
<div className="text-lg font-bold text-red-600 dark:text-red-400 mt-0.5">{summary.inconformidadesCriticas}</div>
<span className="text-[10px] text-red-500/80">furo cego / colisão</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-amber-200 dark:border-amber-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Alertas Furo
</span>
<div className="text-lg font-bold text-amber-600 dark:text-amber-400 mt-0.5">{summary.alertasFuros}</div>
<span className="text-[10px] text-amber-500/80">desalinhados / excêntricos</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-indigo-200 dark:border-indigo-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-indigo-600 dark:text-indigo-400 block">Modificadas / Novas</span>
<div className="text-lg font-bold text-indigo-600 dark:text-indigo-400 mt-0.5">
{summary.modificadas} <span className="text-xs font-normal text-slate-400">/ +{summary.novas}</span>
</div>
<span className="text-[10px] text-slate-400">variação de projeto</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Weight className="w-3 h-3" />
Peso Afetado
</span>
<div className="text-lg font-bold text-slate-900 dark:text-white mt-0.5">{summary.pesoAfetadoKg.toFixed(0)} kg</div>
<span className="text-[10px] text-slate-400">em peças com ocorrência</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Factory className="w-3 h-3" />
Risco Fábrica
</span>
<div
className={`text-sm font-bold uppercase mt-1 px-2 py-0.5 rounded text-center ${
summary.riscoGeral === 'critico'
? 'bg-red-500/10 text-red-500'
: summary.riscoGeral === 'alto'
? 'bg-amber-500/10 text-amber-500'
: 'bg-emerald-500/10 text-emerald-500'
}`}
>
{summary.riscoGeral}
</div>
<span className="text-[10px] text-slate-400 text-center block mt-0.5">retrabalho potencial</span>
</div>
</div>
)}
{/* ── ÁREA PRINCIPAL DIVIDIDA (SPLIT RESPONSIVO) ── */}
<div className="flex-1 flex flex-col lg:flex-row gap-3 min-h-0 overflow-hidden">
{/* PAINEL LATERAL ESQUERDO: FILTROS & LISTA DE OCORRÊNCIAS */}
<div className="w-full lg:w-[420px] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden">
{/* Barra de Filtros */}
<div className="p-3 border-b border-slate-200 dark:border-slate-800 space-y-2.5 bg-slate-50/50 dark:bg-slate-900/50">
{/* Campo de Busca por Peça */}
<div className="relative">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-2.5" />
<input
type="text"
placeholder="Buscar por marca da peça (ex: CH-01, V-101)..."
value={searchPiece}
onChange={(e) => setSearchPiece(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-xs bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{searchPiece && (
<button
onClick={() => setSearchPiece('')}
className="absolute right-2.5 top-2 text-xs text-slate-400 hover:text-slate-600 dark:hover:text-white"
>
×
</button>
)}
</div>
{/* Filtros em Grade */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<label className="text-[10px] font-semibold text-slate-500 dark:text-slate-400 block mb-0.5">
Severidade
</label>
<select
value={filterSeverity}
onChange={(e) => setFilterSeverity(e.target.value)}
className="w-full px-2 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 text-xs"
>
<option value="all">Todas ({defects.length})</option>
<option value="critico">Crítico</option>
<option value="alerta">Alerta</option>
<option value="informativo">Informativo</option>
</select>
</div>
<div>
<label className="text-[10px] font-semibold text-slate-500 dark:text-slate-400 block mb-0.5">
Tipo de Ocorrência
</label>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="w-full px-2 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 text-xs"
>
<option value="all">Todos os tipos</option>
<option value="furo_cego_faltante">Furo Cego (Falta Contra-Furo)</option>
<option value="deslocamento_furo">Furo Deslocado</option>
<option value="furo_extra_revisao">Novo Furo Adicionado</option>
<option value="furo_removido_revisao">Furo Removido</option>
<option value="colisao_geometrica">Colisão / Clash</option>
<option value="peca_excluida">Peça Excluída</option>
<option value="peca_nova">Peça Nova</option>
</select>
</div>
</div>
{/* Seletor de Modo de Cores 3D */}
<div className="pt-1 flex items-center justify-between text-xs">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">Colorização 3D:</span>
<div className="flex items-center gap-1">
<button
onClick={() => setColorizationMode('status_defeito')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'status_defeito'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Defeito
</button>
<button
onClick={() => setColorizationMode('diff_revisao')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'diff_revisao'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Revisão
</button>
<button
onClick={() => setColorizationMode('risco_fabril')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'risco_fabril'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Risco Fábrica
</button>
</div>
</div>
</div>
{/* Lista de Ocorrências (Scrollável) */}
<div className="flex-1 overflow-y-auto p-2.5 space-y-2">
{isLoadingModel ? (
<div className="flex flex-col items-center justify-center h-48 text-center p-4">
<RefreshCw className="w-7 h-7 text-indigo-500 animate-spin mb-2" />
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">{loadingStep}</span>
<span className="text-[11px] text-slate-400 mt-1">{loadingPercent}% processado</span>
</div>
) : filteredDefects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center p-4 text-slate-400">
<CheckCircle2 className="w-8 h-8 text-emerald-500 mb-2" />
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">
Nenhuma inconformidade encontrada
</p>
<p className="text-[11px] text-slate-400 mt-0.5">
Todas as furações e geometrias conferem com os parâmetros e filtros ativos.
</p>
</div>
) : (
filteredDefects.map((defect) => {
const isSelected = selectedDefect?.id === defect.id || selectedPieceMark === defect.pecaMarca;
return (
<div
key={defect.id}
onClick={() => {
setSelectedDefect(defect);
setSelectedPieceMark(defect.pecaMarca);
}}
className={`p-3 rounded-lg border transition-all cursor-pointer ${
isSelected
? 'bg-indigo-50 dark:bg-indigo-950/40 border-indigo-500 shadow-md ring-1 ring-indigo-500'
: 'bg-white dark:bg-slate-800/80 border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-slate-900 dark:text-white text-xs bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded">
{defect.pecaMarca}
</span>
{defect.conjuntoMarca && (
<span className="text-[10px] font-mono text-slate-500 dark:text-slate-400">
(Conj: {defect.conjuntoMarca})
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{defect.gravidade === 'critico' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-100 dark:bg-red-950/60 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
CRÍTICO
</span>
)}
{defect.gravidade === 'alerta' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-amber-100 dark:bg-amber-950/60 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-800">
ALERTA
</span>
)}
{defect.gravidade === 'informativo' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-100 dark:bg-blue-950/60 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
NOVO
</span>
)}
</div>
</div>
<h4 className="text-xs font-semibold text-slate-800 dark:text-slate-200 mb-1 leading-tight">
{defect.titulo}
</h4>
<p className="text-[11px] text-slate-600 dark:text-slate-400 line-clamp-2 leading-relaxed mb-2">
{defect.descricao}
</p>
{/* Rodapé do Card com Status Fabril e Ação */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-slate-700/60 text-[11px]">
<div className="flex items-center gap-1.5">
<Factory className="w-3 h-3 text-slate-400" />
<span className="text-slate-500 dark:text-slate-400">
{defect.processoAtual || 'Pendente'}
</span>
<span
className={`text-[10px] font-bold uppercase px-1 rounded ${
defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto'
? 'text-red-500 bg-red-50 dark:bg-red-950/50'
: 'text-slate-400'
}`}
>
Risco: {defect.riscoFabril}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setDetailModalDefect(defect);
}}
className="text-indigo-600 dark:text-indigo-400 hover:underline font-semibold text-[11px]"
>
Ver Detalhes
</button>
</div>
</div>
);
})
)}
</div>
</div>
{/* VISUALIZADOR 3D CENTRAL */}
<div className="flex-1 flex flex-col min-h-[350px] relative">
<AnalyzerViewer3D
modelData={modelBase}
defects={defects}
selectedDefect={selectedDefect}
selectedPieceMark={selectedPieceMark}
colorizationMode={colorizationMode}
onSelectDefect={(def) => setSelectedDefect(def)}
onSelectPiece={(mark) => setSelectedPieceMark(mark)}
isGhostMode={isGhostMode}
onToggleGhostMode={() => setIsGhostMode(!isGhostMode)}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
theme={isDarkTheme ? 'dark' : 'light'}
selectedOF={selectedOF}
initialRotation={currentOFObj?.model_3d_rotation || null}
/>
</div>
</div>
{/* MODAL DETALHADO DA PEÇA AUDITADA */}
<PieceAuditDetailModal
isOpen={!!detailModalDefect}
onClose={() => setDetailModalDefect(null)}
defect={detailModalDefect}
onFocus3D={(coords) => {
if (detailModalDefect) {
setSelectedDefect(detailModalDefect);
setSelectedPieceMark(detailModalDefect.pecaMarca);
}
}}
/>
</div>
);
}
+12
View File
@@ -15,6 +15,7 @@ import { FichaTecnicaPreview } from '@/components/forms/FichaTecnicaPreview';
import { useThemeConfig } from '@/hooks/useThemeConfig'; import { useThemeConfig } from '@/hooks/useThemeConfig';
import { useFichaTecnica, FichaTecnicaData } from '@/hooks/useFichaTecnica'; import { useFichaTecnica, FichaTecnicaData } from '@/hooks/useFichaTecnica';
import { useUserManagement } from '@/hooks/useUserManagement'; import { useUserManagement } from '@/hooks/useUserManagement';
import { usePermissions } from '@/hooks/usePermissions';
interface InfoCalculoEstrutural { interface InfoCalculoEstrutural {
e: boolean; e: boolean;
@@ -179,6 +180,7 @@ const CadastroOF = () => {
const { themeConfig } = useThemeConfig(); const { themeConfig } = useThemeConfig();
const { buscarFichaTecnica, salvarFichaTecnica, loading: fichaTecnicaLoading } = useFichaTecnica(); const { buscarFichaTecnica, salvarFichaTecnica, loading: fichaTecnicaLoading } = useFichaTecnica();
const { users } = useUserManagement(); const { users } = useUserManagement();
const { canRegra } = usePermissions();
const [formData, setFormData] = useState<FormData>(initialFormData); const [formData, setFormData] = useState<FormData>(initialFormData);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -288,6 +290,16 @@ const CadastroOF = () => {
return; return;
} }
if (!isEditing && !canRegra('eng_criar_of')) {
toast.error('Atenção: Você não possui permissão para cadastrar novas Ordens de Fabricação.');
return;
}
if (isEditing && !canRegra('eng_editar_of')) {
toast.error('Atenção: Você não possui permissão para alterar dados desta Ordem de Fabricação.');
return;
}
setIsSaving(true); setIsSaving(true);
try { try {
+30
View File
@@ -9,6 +9,9 @@ import { usePecasP4Listener } from '@/hooks/usePecasP4Listener';
import { PecaForm } from '@/components/pecas/PecaForm'; import { PecaForm } from '@/components/pecas/PecaForm';
import { PecasTable } from '@/components/pecas/PecasTable'; import { PecasTable } from '@/components/pecas/PecasTable';
import { ComponentesPopup } from '@/components/pecas/ComponentesPopup'; import { ComponentesPopup } from '@/components/pecas/ComponentesPopup';
import { RevisaoPecaModal } from '@/components/pecas/revisoes/RevisaoPecaModal';
import { PainelRevisoesOFModal } from '@/components/pecas/revisoes/PainelRevisoesOFModal';
import { useRevisoes } from '@/hooks/useRevisoes';
import { Package, Plus, RefreshCw } from 'lucide-react'; import { Package, Plus, RefreshCw } from 'lucide-react';
import { usePermissionControl } from '@/hooks/usePermissionControl'; import { usePermissionControl } from '@/hooks/usePermissionControl';
@@ -39,6 +42,8 @@ export default function CadastroPecas() {
const [editingPeca, setEditingPeca] = useState<Peca | null>(null); const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null); const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
const [pecaParaRevisao, setPecaParaRevisao] = useState<Peca | null>(null);
const [showPainelRevisoes, setShowPainelRevisoes] = useState(false);
const handleSave = async (formData: any) => { const handleSave = async (formData: any) => {
if (!canCreate()) return false; if (!canCreate()) return false;
@@ -242,6 +247,8 @@ export default function CadastroPecas() {
onDeletePecasBatch={canDelete() ? handleDeleteBatch : undefined} onDeletePecasBatch={canDelete() ? handleDeleteBatch : undefined}
onEditPeca={canEdit() ? handleEdit : undefined} onEditPeca={canEdit() ? handleEdit : undefined}
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined} onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
onOpenRevisaoPeca={(p) => setPecaParaRevisao(p)}
onOpenPainelRevisoes={() => setShowPainelRevisoes(true)}
hasRecentImport={hasRecentImport} hasRecentImport={hasRecentImport}
canEdit={canEdit()} canEdit={canEdit()}
canDelete={canDelete()} canDelete={canDelete()}
@@ -260,6 +267,29 @@ export default function CadastroPecas() {
peca={selectedPecaForPopup} peca={selectedPecaForPopup}
/> />
)} )}
{/* Modal de Gestão de Revisão da Peça */}
{pecaParaRevisao && (
<RevisaoPecaModal
isOpen={!!pecaParaRevisao}
onClose={() => setPecaParaRevisao(null)}
peca={pecaParaRevisao}
onPecaAtualizada={() => {
loadPecas();
}}
/>
)}
{/* Modal Painel Geral de Revisões */}
{showPainelRevisoes && (
<PainelRevisoesOFModal
isOpen={showPainelRevisoes}
onClose={() => setShowPainelRevisoes(false)}
ofNumber={pecas[0]?.of_number || ''}
pecas={pecas}
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
/>
)}
</div> </div>
</div> </div>
); );
+46 -2
View File
@@ -7,7 +7,10 @@ import { ArrowLeft, Plus, Package } from 'lucide-react';
import { PecaForm } from '@/components/pecas/PecaForm'; import { PecaForm } from '@/components/pecas/PecaForm';
import { PecasTable } from '@/components/pecas/PecasTable'; import { PecasTable } from '@/components/pecas/PecasTable';
import { ComponentesPopup } from '@/components/pecas/ComponentesPopup'; import { ComponentesPopup } from '@/components/pecas/ComponentesPopup';
import { RevisaoPecaModal } from '@/components/pecas/revisoes/RevisaoPecaModal';
import { PainelRevisoesOFModal } from '@/components/pecas/revisoes/PainelRevisoesOFModal';
import { usePecas, Peca } from '@/hooks/usePecas'; import { usePecas, Peca } from '@/hooks/usePecas';
import { useRevisoes } from '@/hooks/useRevisoes';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { usePermissionControl } from '@/hooks/usePermissionControl'; import { usePermissionControl } from '@/hooks/usePermissionControl';
import { StandardPageLayout } from '@/components/layout/StandardPageLayout'; import { StandardPageLayout } from '@/components/layout/StandardPageLayout';
@@ -34,11 +37,15 @@ export default function CadastroPecasFiltrado() {
batchUpdatePecas, batchUpdatePecas,
loadPecas loadPecas
} = usePecas(); } = usePecas();
const { revisoes: revisoesOF, carregarRevisoesOF } = useRevisoes(ofSelecionada);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [editingPeca, setEditingPeca] = useState<Peca | null>(null); const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null); const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
const [pecaParaRevisao, setPecaParaRevisao] = useState<Peca | null>(null);
const [showPainelRevisoes, setShowPainelRevisoes] = useState(false);
// Filtrar peças pela OF selecionada // Filtrar peças pela OF selecionada
const pecasFiltradas = pecas.filter(peca => peca.of_number === ofSelecionada); const pecasFiltradas = pecas.filter(peca => peca.of_number === ofSelecionada);
@@ -170,8 +177,10 @@ export default function CadastroPecasFiltrado() {
useEffect(() => { useEffect(() => {
if (!ofSelecionada) { if (!ofSelecionada) {
navigate('/seletor-of'); navigate('/seletor-of');
} else {
carregarRevisoesOF(ofSelecionada);
} }
}, [ofSelecionada, navigate]); }, [ofSelecionada, navigate, carregarRevisoesOF]);
if (loading) { if (loading) {
return ( return (
@@ -184,6 +193,14 @@ export default function CadastroPecasFiltrado() {
); );
} }
const handleVoltar = () => {
if (window.history.state && window.history.state.idx > 0) {
navigate(-1);
} else {
navigate('/seletor-of');
}
};
if (!ofSelecionada) { if (!ofSelecionada) {
return null; return null;
} }
@@ -197,7 +214,7 @@ export default function CadastroPecasFiltrado() {
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => navigate('/ordens-fabricacao')} onClick={handleVoltar}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -269,6 +286,9 @@ export default function CadastroPecasFiltrado() {
onEditPeca={canEdit() ? handleEdit : undefined} onEditPeca={canEdit() ? handleEdit : undefined}
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined} onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
onBatchUpdatePecas={canEdit() ? handleBatchUpdatePecas : undefined} onBatchUpdatePecas={canEdit() ? handleBatchUpdatePecas : undefined}
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
onOpenPainelRevisoes={() => setShowPainelRevisoes(true)}
totalRevisoesOF={revisoesOF.length}
hasRecentImport={hasRecentImport} hasRecentImport={hasRecentImport}
canEdit={canEdit()} canEdit={canEdit()}
canDelete={canDelete()} canDelete={canDelete()}
@@ -288,6 +308,30 @@ export default function CadastroPecasFiltrado() {
peca={selectedPecaForPopup} peca={selectedPecaForPopup}
/> />
)} )}
{/* Modal de Gestão de Revisão da Peça */}
{pecaParaRevisao && (
<RevisaoPecaModal
isOpen={!!pecaParaRevisao}
onClose={() => setPecaParaRevisao(null)}
peca={pecaParaRevisao}
onPecaAtualizada={() => {
loadPecas();
carregarRevisoesOF(ofSelecionada);
}}
/>
)}
{/* Modal Painel Geral de Revisões da OF */}
{showPainelRevisoes && (
<PainelRevisoesOFModal
isOpen={showPainelRevisoes}
onClose={() => setShowPainelRevisoes(false)}
ofNumber={ofSelecionada}
pecas={pecasFiltradas}
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
/>
)}
</div> </div>
</StandardPageLayout> </StandardPageLayout>
); );
+34 -11
View File
@@ -13,6 +13,8 @@ import { useState, useEffect } from 'react';
import { useDiarioProducao, type RecursoProducao, type ApontamentoDiarioRecurso, type LoteSoldaDiario } from '@/hooks/useDiarioProducao'; import { useDiarioProducao, type RecursoProducao, type ApontamentoDiarioRecurso, type LoteSoldaDiario } from '@/hooks/useDiarioProducao';
import { useUserProfile } from '@/hooks/useUserProfile'; import { useUserProfile } from '@/hooks/useUserProfile';
import { useFichaTecnica } from '@/hooks/useFichaTecnica'; import { useFichaTecnica } from '@/hooks/useFichaTecnica';
import { usePermissions } from '@/hooks/usePermissions';
import { toast } from 'sonner';
interface DiarioData { interface DiarioData {
id?: string; id?: string;
@@ -28,6 +30,7 @@ interface DiarioData {
} }
const DiarioProducao = () => { const DiarioProducao = () => {
const { canRegra } = usePermissions();
const [activeTab, setActiveTab] = useState('diario'); const [activeTab, setActiveTab] = useState('diario');
const [selectedOF, setSelectedOF] = useState(''); const [selectedOF, setSelectedOF] = useState('');
const [showOFModal, setShowOFModal] = useState(false); const [showOFModal, setShowOFModal] = useState(false);
@@ -131,6 +134,12 @@ const DiarioProducao = () => {
const salvarDiarioCompleto = async () => { const salvarDiarioCompleto = async () => {
try { try {
const hoje = new Date().toISOString().split('T')[0];
if (currentDiario.data < hoje && !canRegra('pcp_retroativo_permitido')) {
toast.error('Atenção: Você não possui permissão para apontar produção com data retroativa.');
return;
}
// 1. Salvar o diário principal // 1. Salvar o diário principal
const diarioSalvo = await salvarDiario.mutateAsync({ const diarioSalvo = await salvarDiario.mutateAsync({
data: currentDiario.data, data: currentDiario.data,
@@ -608,17 +617,31 @@ const DiarioProducao = () => {
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="sm"> {canRegra('pcp_exportar_relatorios') && (
<Printer className="h-4 w-4" /> <Button
</Button> variant="ghost"
<Button size="sm"
variant="ghost" title="Exportar/Imprimir Diário"
size="sm" onClick={() => window.print()}
onClick={() => diario.id && deletarDiario.mutate(diario.id)} >
disabled={deletarDiario.isPending} <Printer className="h-4 w-4" />
> </Button>
<Trash2 className="h-4 w-4 text-destructive" /> )}
</Button> {canRegra('pcp_excluir_apontamento') && (
<Button
variant="ghost"
size="sm"
title="Excluir Diário"
onClick={() => {
if (confirm('Tem certeza que deseja excluir este diário de produção?')) {
diario.id && deletarDiario.mutate(diario.id);
}
}}
disabled={deletarDiario.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div> </div>
</td> </td>
</tr> </tr>
+14 -4
View File
@@ -29,8 +29,11 @@ import { useOFsAtivas } from '@/hooks/useOFsAtivas';
import { useMobileResponsive } from '@/hooks/useMobileResponsive'; import { useMobileResponsive } from '@/hooks/useMobileResponsive';
import { RomaneioExpedicao } from '@/hooks/useRomaneios'; import { RomaneioExpedicao } from '@/hooks/useRomaneios';
import { RelatoriosExpedicao } from '@/components/expedicao/RelatoriosExpedicao'; import { RelatoriosExpedicao } from '@/components/expedicao/RelatoriosExpedicao';
import { usePermissions } from '@/hooks/usePermissions';
import { toast } from 'sonner';
const Expedicao = () => { const Expedicao = () => {
const { canRegra } = usePermissions();
const { isMobile } = useMobileResponsive(); const { isMobile } = useMobileResponsive();
const { data: romaneios, isLoading, refetch } = useRomaneios(); const { data: romaneios, isLoading, refetch } = useRomaneios();
const { data: ofsAtivas, isLoading: isLoadingOFs } = useOFsAtivas(); const { data: ofsAtivas, isLoading: isLoadingOFs } = useOFsAtivas();
@@ -104,6 +107,11 @@ const Expedicao = () => {
}; };
const confirmDelete = async () => { const confirmDelete = async () => {
if (!canRegra('exp_cancelar_estornar')) {
toast.error('Atenção: Você não possui permissão para cancelar ou estornar romaneios de expedição.');
return;
}
if (romaneioToDelete) { if (romaneioToDelete) {
try { try {
await removerRomaneioMutation.mutateAsync(romaneioToDelete); await removerRomaneioMutation.mutateAsync(romaneioToDelete);
@@ -321,10 +329,12 @@ const Expedicao = () => {
</Sheet> </Sheet>
)} )}
<Button size={isMobile ? "sm" : "default"} onClick={() => setShowRomaneioForm(true)}> {canRegra('exp_criar_romaneio') && (
<Plus className="w-4 h-4 mr-2" /> <Button size={isMobile ? "sm" : "default"} onClick={() => setShowRomaneioForm(true)}>
Novo Romaneio <Plus className="w-4 h-4 mr-2" />
</Button> Novo Romaneio
</Button>
)}
</div> </div>
</div> </div>
+1 -46
View File
@@ -6,7 +6,6 @@ import { Users, UserPlus, Shield, Settings, Mail, Activity, LayoutGrid } from 'l
import { UsersTable } from '@/components/users/UsersTable'; import { UsersTable } from '@/components/users/UsersTable';
import { PendingUsersTable } from '@/components/users/PendingUsersTable'; import { PendingUsersTable } from '@/components/users/PendingUsersTable';
import { FunctionsManager } from '@/components/users/FunctionsManager'; import { FunctionsManager } from '@/components/users/FunctionsManager';
import { PrivilegesManager } from '@/components/users/PrivilegesManager';
import { PasswordResetRequests } from '@/components/users/PasswordResetRequests'; import { PasswordResetRequests } from '@/components/users/PasswordResetRequests';
import { SessionLogsSimple } from '@/components/users/SessionLogsSimple'; import { SessionLogsSimple } from '@/components/users/SessionLogsSimple';
import { UserModal } from '@/components/users/UserModal'; import { UserModal } from '@/components/users/UserModal';
@@ -40,9 +39,6 @@ const UserManagement = () => {
createFunction, createFunction,
updateFunction, updateFunction,
deleteFunction, deleteFunction,
createPrivilege: createPrivilegeBase,
updatePrivilege: updatePrivilegeBase,
deletePrivilege,
updateUser, updateUser,
toggleUserStatus, toggleUserStatus,
deleteUser, deleteUser,
@@ -67,15 +63,6 @@ const UserManagement = () => {
); );
} }
// Wrapper functions to match expected signatures
const createPrivilege = async (data: { name: string; description?: string; permissions: Record<string, boolean> }): Promise<void> => {
await createPrivilegeBase(data);
};
const updatePrivilege = async (id: string, data: { name: string; description?: string; permissions: Record<string, boolean> }): Promise<void> => {
await updatePrivilegeBase(id, data);
};
const handleEditUser = (user: UserProfile) => { const handleEditUser = (user: UserProfile) => {
if (!canEdit()) return; if (!canEdit()) return;
setSelectedUser(user); setSelectedUser(user);
@@ -158,7 +145,7 @@ const UserManagement = () => {
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-7 bg-muted h-auto p-1"> <TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-6 bg-muted h-auto p-1">
<TabsTrigger <TabsTrigger
value="matriz" value="matriz"
className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3 font-semibold text-primary data-[state=active]:bg-primary data-[state=active]:text-primary-foreground" className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3 font-semibold text-primary data-[state=active]:bg-primary data-[state=active]:text-primary-foreground"
@@ -210,17 +197,6 @@ const UserManagement = () => {
</TabsTrigger> </TabsTrigger>
)} )}
{canEdit() && (
<TabsTrigger
value="privileges"
className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3"
>
<Shield className="h-3 w-3 md:h-4 md:w-4" />
<span className="hidden sm:inline">Privilégios</span>
<span className="sm:hidden">Priv</span>
</TabsTrigger>
)}
{canView() && ( {canView() && (
<TabsTrigger <TabsTrigger
value="session-logs" value="session-logs"
@@ -311,27 +287,6 @@ const UserManagement = () => {
</TabsContent> </TabsContent>
)} )}
{canEdit() && (
<TabsContent value="privileges" className="space-y-4 mt-4">
<Card className="card-mobile">
<CardHeader className="card-header-mobile">
<CardTitle className="text-lg md:text-xl flex items-center gap-2">
<Shield className="h-4 w-4 md:h-5 md:w-5" />
Gerenciar Privilégios
</CardTitle>
</CardHeader>
<CardContent className="card-content-mobile">
<PrivilegesManager
privileges={privileges as any}
onCreate={canCreate() ? createPrivilege : undefined}
onUpdate={canEdit() ? updatePrivilege : undefined}
onDelete={canDelete() ? deletePrivilege : undefined}
/>
</CardContent>
</Card>
</TabsContent>
)}
{canView() && ( {canView() && (
<TabsContent value="session-logs" className="space-y-4 mt-4"> <TabsContent value="session-logs" className="space-y-4 mt-4">
<SessionLogsSimple users={users} /> <SessionLogsSimple users={users} />
+1 -1
View File
@@ -695,7 +695,7 @@ export default function Visualizador3D() {
</div> </div>
{/* Main 3D Viewport */} {/* Main 3D Viewport */}
<div className="relative flex-1 min-h-[480px] w-full bg-slate-100 dark:bg-slate-950 rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-xl dark:shadow-2xl"> <div className="relative flex-1 min-h-[560px] w-full bg-slate-100 dark:bg-slate-950 rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-xl dark:shadow-2xl">
{isLoadingModel && ( {isLoadingModel && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center p-6 bg-white dark:bg-slate-950/90 backdrop-blur-md text-slate-900 dark:text-white space-y-4"> <div className="absolute inset-0 z-30 flex flex-col items-center justify-center p-6 bg-white dark:bg-slate-950/90 backdrop-blur-md text-slate-900 dark:text-white space-y-4">
<div className="p-4 bg-cyan-100 text-cyan-600 dark:bg-cyan-500/20 dark:text-cyan-400 rounded-2xl border border-cyan-300 dark:border-cyan-500/40 animate-pulse"> <div className="p-4 bg-cyan-100 text-cyan-600 dark:bg-cyan-500/20 dark:text-cyan-400 rounded-2xl border border-cyan-300 dark:border-cyan-500/40 animate-pulse">
+59
View File
@@ -0,0 +1,59 @@
export type DefectSeverity = 'critico' | 'alerta' | 'informativo';
export type DefectType =
| 'furo_desalinhado'
| 'furo_cego_faltante'
| 'furo_extra_revisao'
| 'furo_removido_revisao'
| 'deslocamento_furo'
| 'colisao_geometrica'
| 'peca_modificada'
| 'peca_nova'
| 'peca_excluida'
| 'divergencia_dimensional';
export type ProductionRisk = 'baixo' | 'medio' | 'alto' | 'critico';
export interface DefectItem {
id: string;
tipo: DefectType;
gravidade: DefectSeverity;
titulo: string;
descricao: string;
pecaMarca: string;
conjuntoMarca?: string;
expressID?: number;
guid?: string;
fase?: string;
quantidadeAfetada: number;
pesoUnitarioKg?: number;
pesoTotalKg?: number;
// Detalhes de furação
diametroMm?: number;
deslocamentoMm?: { x: number; y: number; z: number };
pecaConectadaMarca?: string;
pecaConectadaConjunto?: string;
// Cruzamento com chão de fábrica
processoAtual?: string;
processoCor?: string;
quantidadeApontada?: number;
riscoFabril: ProductionRisk;
recomendacao: string;
// Coordenadas aproximadas para foco 3D
coordenadas3D?: { x: number; y: number; z: number };
}
export interface ComparisonDiffSummary {
totalPecasBase: number;
totalPecasRevisao: number;
inalteradas: number;
modificadas: number;
novas: number;
excluidas: number;
inconformidadesCriticas: number;
alertasFuros: number;
pesoAfetadoKg: number;
riscoGeral: ProductionRisk;
}
export type ColorizationMode = 'status_defeito' | 'diff_revisao' | 'risco_fabril' | 'processo';
+421
View File
@@ -0,0 +1,421 @@
export interface RegraAvancada {
key: string;
nome: string;
descricao: string;
icone: string;
padraoAtivo?: boolean;
}
export interface GrupoAvancadoConfig {
key: string;
titulo: string;
cor: 'sky' | 'indigo' | 'amber' | 'teal' | 'rose';
icone: string;
descricao: string;
regras: RegraAvancada[];
templates: {
id: string;
nome: string;
descricao: string;
icone: string;
regrasAtivas: string[];
}[];
}
export const GRUPOS_AVANCADOS_CONFIG: Record<string, GrupoAvancadoConfig> = {
// 1. PRODUÇÃO & PCP
producao_pcp: {
key: 'producao_pcp',
titulo: 'Produção & PCP',
cor: 'sky',
icone: 'Factory',
descricao: 'Controle de apontamentos de fábrica, fechamento de diários e reordenação de filas.',
regras: [
{
key: 'pcp_retroativo_permitido',
nome: 'Apontamento Retroativo de Produção',
descricao: 'Permite apontar produção com datas anteriores ao dia de trabalho atual.',
icone: 'Calendar',
padraoAtivo: false,
},
{
key: 'pcp_fechar_diario',
nome: 'Fechamento & Homologação do Diário',
descricao: 'Autorização para encerrar e consolidar o diário de produção do dia.',
icone: 'Lock',
padraoAtivo: false,
},
{
key: 'pcp_reordenar_prioridades',
nome: 'Reordenação da Fila de Fabricação',
descricao: 'Permite alterar a prioridade de fabricação e a ordem de corte/montagem das OFs.',
icone: 'ArrowUpDown',
padraoAtivo: false,
},
{
key: 'pcp_apontamento_massa',
nome: 'Apontamento em Lote / Massa',
descricao: 'Libera o avanço conjunto de múltiplas peças simultaneamente.',
icone: 'Layers',
padraoAtivo: true,
},
{
key: 'pcp_excluir_apontamento',
nome: 'Exclusão & Estorno de Apontamentos',
descricao: 'Permite cancelar ou excluir apontamentos de produção já registrados.',
icone: 'Trash2',
padraoAtivo: false,
},
{
key: 'pcp_exportar_relatorios',
nome: 'Exportação de Relatórios de Produção',
descricao: 'Download de relatórios em PDF e planilhas de avanço e produtividade fabril.',
icone: 'FileSpreadsheet',
padraoAtivo: true,
},
],
templates: [
{
id: 'operador_pcp',
nome: 'Apontador Fabril Padrão',
descricao: 'Apontamento normal em lote de peças, sem autorização para exclusões ou datas retroativas.',
icone: 'Wrench',
regrasAtivas: ['pcp_apontamento_massa'],
},
{
id: 'lider_producao',
nome: 'Líder / Encarregado de Fábrica',
descricao: 'Liberação de apontamentos retroativos, reordenação de filas e exportação de relatórios.',
icone: 'Shield',
regrasAtivas: ['pcp_retroativo_permitido', 'pcp_reordenar_prioridades', 'pcp_apontamento_massa', 'pcp_exportar_relatorios'],
},
{
id: 'gestor_pcp_total',
nome: 'Planejador PCP Total',
descricao: 'Acesso pleno ao módulo: fechamento de diários, estornos, reordenação e relatórios.',
icone: 'Crown',
regrasAtivas: [
'pcp_retroativo_permitido',
'pcp_fechar_diario',
'pcp_reordenar_prioridades',
'pcp_apontamento_massa',
'pcp_excluir_apontamento',
'pcp_exportar_relatorios',
],
},
],
},
// 2. ENGENHARIA, OFS & BIM
engenharia_3d: {
key: 'engenharia_3d',
titulo: 'Engenharia, OFs & BIM',
cor: 'indigo',
icone: 'Box',
descricao: 'Criação de OFs, importação de dados Tekla/XML/DSTV, cadastro de peças e modelos 3D.',
regras: [
{
key: 'eng_criar_of',
nome: 'Criação de Nova Ordem de Fabricação (OF)',
descricao: 'Libera o botão de cadastro de novas ordens e definição de cliente e contrato.',
icone: 'PlusCircle',
padraoAtivo: false,
},
{
key: 'eng_editar_of',
nome: 'Edição de Dados & Metadados da OF',
descricao: 'Permite alterar prazos, peso contratual, cliente e informações gerais da OF.',
icone: 'Edit3',
padraoAtivo: false,
},
{
key: 'eng_excluir_of',
nome: 'Exclusão ou Cancelamento de OF',
descricao: 'Ação crítica de remoção ou cancelamento permanente de Ordens de Fabricação.',
icone: 'Trash2',
padraoAtivo: false,
},
{
key: 'eng_importar_dados_tecnicos',
nome: 'Importação de Dados Técnicos (XML / DSTV / Tekla)',
descricao: 'Upload e processamento de arquivos de engenharia e listas de peças.',
icone: 'UploadCloud',
padraoAtivo: true,
},
{
key: 'eng_gerenciar_pecas',
nome: 'Cadastro, Edição & Exclusão de Peças',
descricao: 'Permite adicionar novas marcas, ajustar perfis, comprimentos e pesos na lista técnica.',
icone: 'Network',
padraoAtivo: false,
},
{
key: 'eng_download_ifc',
nome: 'Download de Modelos 3D & Arquivos IFC',
descricao: 'Permite exportar e baixar os modelos tridimensionais brutos e arquivos IFC/BIM.',
icone: 'Download',
padraoAtivo: true,
},
{
key: 'eng_exportar_listas',
nome: 'Exportação de Listas de Materiais & Peças',
descricao: 'Emissão de relatórios técnicos, listas de corte e resumo de pesos.',
icone: 'FileText',
padraoAtivo: true,
},
],
templates: [
{
id: 'desenhista_detalhista',
nome: 'Detalhista / Projetista',
descricao: 'Focado em importação técnica, download de modelos IFC e geração de listas de corte.',
icone: 'Compass',
regrasAtivas: ['eng_importar_dados_tecnicos', 'eng_download_ifc', 'eng_exportar_listas'],
},
{
id: 'engenheiro_estrutural',
nome: 'Engenheiro de Estruturas',
descricao: 'Criação de OFs, edição técnica de peças, importação de dados e relatórios.',
icone: 'Shield',
regrasAtivas: ['eng_criar_of', 'eng_editar_of', 'eng_importar_dados_tecnicos', 'eng_gerenciar_pecas', 'eng_download_ifc', 'eng_exportar_listas'],
},
{
id: 'coordenador_engenharia_total',
nome: 'Coordenador de Engenharia Total',
descricao: 'Controle irrestrito de engenharia, incluindo exclusão e cancelamento de OFs.',
icone: 'Crown',
regrasAtivas: [
'eng_criar_of',
'eng_editar_of',
'eng_excluir_of',
'eng_importar_dados_tecnicos',
'eng_gerenciar_pecas',
'eng_download_ifc',
'eng_exportar_listas',
],
},
],
},
// 3. EXPEDIÇÃO, ESTOQUE & ROMANEIOS
expedicao_logistica: {
key: 'expedicao_logistica',
titulo: 'Expedição, Estoque & Romaneios',
cor: 'amber',
icone: 'Truck',
descricao: 'Gestão de cargas, romaneios de embarque, controle de estoque e solicitações de compra.',
regras: [
{
key: 'exp_criar_romaneio',
nome: 'Criação de Cargas & Romaneios',
descricao: 'Permite abrir uma nova carga de expedição e definir transportadora e motorista.',
icone: 'PlusSquare',
padraoAtivo: false,
},
{
key: 'exp_adicionar_remover_pecas',
nome: 'Montagem de Romaneio (Adicionar/Remover Peças)',
descricao: 'Autorização para incluir ou retirar peças e conjuntos da carga.',
icone: 'PackageCheck',
padraoAtivo: true,
},
{
key: 'exp_despachar_carga',
nome: 'Despacho & Finalização de Romaneio',
descricao: 'Permite concluir o romaneio e marcar a carga como despachada/enviada.',
icone: 'CheckSquare',
padraoAtivo: false,
},
{
key: 'exp_cancelar_estornar',
nome: 'Cancelamento & Estorno de Cargas Despachadas',
descricao: 'Ação crítica de cancelar romaneios enviados, com devolução das peças ao pátio.',
icone: 'RotateCcw',
padraoAtivo: false,
},
{
key: 'exp_imprimir_documentos',
nome: 'Emissão de Romaneios PDF & Etiquetas',
descricao: 'Permite imprimir o espelho do romaneio e etiquetas de identificação de peças.',
icone: 'Printer',
padraoAtivo: true,
},
{
key: 'est_ajuste_manual_saldo',
nome: 'Ajuste Manual de Saldo de Estoque',
descricao: 'Permite alterar quantidades físicas de chapas, perfis e consumíveis no inventário.',
icone: 'SlidersHorizontal',
padraoAtivo: false,
},
{
key: 'est_entrada_material',
nome: 'Entrada de Matérias-Primas & NFs',
descricao: 'Registra novas entradas de aço no estoque de fábrica.',
icone: 'FileInput',
padraoAtivo: false,
},
{
key: 'est_solicitacao_compras',
nome: 'Criação & Gestão de Solicitações de Compras',
descricao: 'Permite solicitar aquisição de materiais e insumos para suprimento das OFs.',
icone: 'ShoppingCart',
padraoAtivo: false,
},
],
templates: [
{
id: 'operador_expedicao',
nome: 'Conferente de Pátio / Expedição',
descricao: 'Adição de peças no romaneio e impressão de espelhos e etiquetas.',
icone: 'Barcode',
regrasAtivas: ['exp_adicionar_remover_pecas', 'exp_imprimir_documentos'],
},
{
id: 'almoxarife_estoquista',
nome: 'Almoxarife / Estoquista',
descricao: 'Entradas de aço, controle de saldo e solicitações de compras.',
icone: 'Box',
regrasAtivas: ['est_entrada_material', 'est_solicitacao_compras', 'exp_imprimir_documentos'],
},
{
id: 'lider_logistica_total',
nome: 'Gestor de Logística & Estoque Total',
descricao: 'Controle total de cargas, despacho, estorno, inventário e compras.',
icone: 'Crown',
regrasAtivas: [
'exp_criar_romaneio',
'exp_adicionar_remover_pecas',
'exp_despachar_carga',
'exp_cancelar_estornar',
'exp_imprimir_documentos',
'est_ajuste_manual_saldo',
'est_entrada_material',
'est_solicitacao_compras',
],
},
],
},
// 4. OBRA & MONTAGEM EXTERNA
obra_montagem: {
key: 'obra_montagem',
titulo: 'Obra & Montagem Externa',
cor: 'teal',
icone: 'HardHat',
descricao: 'Acompanhamento do canteiro, status de montagem de peças e diário de obra.',
regras: [
{
key: 'obra_apontar_montagem',
nome: 'Apontamento de Peças Montadas em Campo',
descricao: 'Permite registrar que conjuntos e peças foram descarregados e montados no canteiro.',
icone: 'CheckCheck',
padraoAtivo: true,
},
{
key: 'obra_registrar_ocorrencia',
nome: 'Registro de Ocorrências & Diário de Campo',
descricao: 'Permite anotar paralisações por clima, problemas de montagem e observações de obra.',
icone: 'AlertCircle',
padraoAtivo: true,
},
{
key: 'obra_configurar_marcos',
nome: 'Configuração de Frentes & Marcos da Obra',
descricao: 'Permite criar e alterar etapas, setores e marcos estruturais do projeto.',
icone: 'Settings2',
padraoAtivo: false,
},
{
key: 'obra_exportar_relatorio',
nome: 'Exportação de Relatórios de Evolução de Obra',
descricao: 'Geração de relatórios gerenciais e evolução percentual da montagem.',
icone: 'FileSpreadsheet',
padraoAtivo: true,
},
],
templates: [
{
id: 'montador_campo',
nome: 'Montador / Encarregado de Campo',
descricao: 'Apontamento de peças no canteiro e registro de ocorrências.',
icone: 'Wrench',
regrasAtivas: ['obra_apontar_montagem', 'obra_registrar_ocorrencia'],
},
{
id: 'engenheiro_obra_total',
nome: 'Engenheiro Residente da Obra',
descricao: 'Acesso irrestrito com configuração de marcos e exportação de relatórios.',
icone: 'Crown',
regrasAtivas: ['obra_apontar_montagem', 'obra_registrar_ocorrencia', 'obra_configurar_marcos', 'obra_exportar_relatorio'],
},
],
},
// 5. ADMINISTRAÇÃO & SISTEMA
admin_sistema: {
key: 'admin_sistema',
titulo: 'Administração & Sistema',
cor: 'rose',
icone: 'Shield',
descricao: 'Governança de acessos, usuários, auditoria de dados e configurações da empresa.',
regras: [
{
key: 'adm_gerenciar_usuarios',
nome: 'Gestão de Usuários & Aprovações',
descricao: 'Permite criar, editar, aprovar novos cadastros e inativar colaboradores.',
icone: 'Users',
padraoAtivo: false,
},
{
key: 'adm_gerenciar_matriz',
nome: 'Gestão da Matriz de Acessos & Permissões',
descricao: 'Autorização para conceder ou revogar acessos básicos e regras avançadas.',
icone: 'ShieldCheck',
padraoAtivo: false,
},
{
key: 'adm_redefinir_senhas',
nome: 'Redefinição de Senhas de Acesso',
descricao: 'Permite gerar links de recuperação e redefinir senhas de colaboradores.',
icone: 'KeyRound',
padraoAtivo: false,
},
{
key: 'adm_auditoria_inconsistencias',
nome: 'Auditoria & Correção de Inconsistências',
descricao: 'Executa varreduras de integridade no banco de dados e aplica correções automáticas.',
icone: 'Activity',
padraoAtivo: false,
},
{
key: 'adm_configuracoes_gerais',
nome: 'Alteração de Parâmetros da Empresa',
descricao: 'Autoriza alterar dados cadastrais da empresa, logotipo e parâmetros globais do ERP.',
icone: 'Settings',
padraoAtivo: false,
},
],
templates: [
{
id: 'administrador_rh',
nome: 'Gestor de Equipe & RH',
descricao: 'Criação de usuários, aprovação de pendentes e redefinição de senhas.',
icone: 'Users',
regrasAtivas: ['adm_gerenciar_usuarios', 'adm_redefinir_senhas'],
},
{
id: 'super_admin_total',
nome: 'Super Administrador Total',
descricao: 'Acesso irrestrito a todas as configurações de governança, sistema e banco de dados.',
icone: 'Crown',
regrasAtivas: [
'adm_gerenciar_usuarios',
'adm_gerenciar_matriz',
'adm_redefinir_senhas',
'adm_auditoria_inconsistencias',
'adm_configuracoes_gerais',
],
},
],
},
};
+220
View File
@@ -0,0 +1,220 @@
export interface SmartSubmodule {
key: string;
nome: string;
descricao: string;
icone: string;
padraoOperador: {
can_view: boolean;
can_edit: boolean;
can_delete: boolean;
};
}
export const SMART_SUBMODULES: SmartSubmodule[] = [
{
key: 'smart_of',
nome: 'Ordens de Fabricação (OF)',
descricao: 'Acesso à seleção, listagem e busca de OFs ativas',
icone: 'FileText',
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
},
{
key: 'smart_fases',
nome: 'Fases & Etapas da Obra',
descricao: 'Filtro e navegação pelas etapas/fases de fabricação',
icone: 'Layers',
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
},
{
key: 'smart_processos',
nome: 'Processos de Fabricação',
descricao: 'Visualização e seleção de processos (Corte, Solda, Pintura, etc.)',
icone: 'Flame',
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
},
{
key: 'smart_apontamento',
nome: 'Apontamento de Peças',
descricao: 'Realização de apontamentos normais de produção no chão de fábrica',
icone: 'CheckCircle2',
padraoOperador: { can_view: true, can_edit: true, can_delete: false },
},
{
key: 'smart_forcar_apontamento',
nome: 'Forçar Apontamento (Avanço sem restrição)',
descricao: 'Permissão para avançar peças furando fila ou com pendências de processos anteriores',
icone: 'Zap',
padraoOperador: { can_view: false, can_edit: false, can_delete: false },
},
{
key: 'smart_3d',
nome: 'Modelo 3D BIM',
descricao: 'Visualizador interativo 3D de peças, montagens e conjuntos',
icone: 'Box',
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
},
{
key: 'smart_modo_completo',
nome: 'Acesso ao Modo Completo ERP',
descricao: 'Permissão para alternar da interface touch para o ERP gerencial',
icone: 'ExternalLink',
padraoOperador: { can_view: false, can_edit: false, can_delete: false },
},
{
key: 'smart_romaneios',
nome: 'Romaneios & Embarque',
descricao: 'Acesso e conferência de romaneios de expedição de peças',
icone: 'Truck',
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
},
{
key: 'smart_montagem',
nome: 'Montagem & Canteiro',
descricao: 'Apontamentos e checklists de montagem externa',
icone: 'Wrench',
padraoOperador: { can_view: true, can_edit: true, can_delete: false },
},
{
key: 'smart_relatorios',
nome: 'Relatórios & Exportação PDF',
descricao: 'Geração de resumos de produção, checklists e exportação de relatórios',
icone: 'FileSpreadsheet',
padraoOperador: { can_view: true, can_edit: true, can_delete: false },
},
];
export interface SmartCustomTemplate {
id: string;
nome: string;
descricao: string;
icone: string;
badge?: string;
isCustom?: boolean;
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>;
}
export const SMART_DEFAULT_TEMPLATES: SmartCustomTemplate[] = [
{
id: 'operador_padrao',
nome: 'Padrão Operador',
descricao: 'Focado em apontamento de peças, montagem e relatórios básicos.',
icone: 'Factory',
badge: 'Chão de Fábrica',
isCustom: false,
permissions: {
smart_of: { can_view: true, can_edit: false, can_delete: false },
smart_fases: { can_view: true, can_edit: false, can_delete: false },
smart_processos: { can_view: true, can_edit: false, can_delete: false },
smart_apontamento: { can_view: true, can_edit: true, can_delete: false },
smart_forcar_apontamento: { can_view: false, can_edit: false, can_delete: false },
smart_3d: { can_view: true, can_edit: false, can_delete: false },
smart_modo_completo: { can_view: false, can_edit: false, can_delete: false },
smart_romaneios: { can_view: true, can_edit: false, can_delete: false },
smart_montagem: { can_view: true, can_edit: true, can_delete: false },
smart_relatorios: { can_view: true, can_edit: true, can_delete: false },
},
},
{
id: 'lider_producao',
nome: 'Líder de Produção',
descricao: 'Permite forçar apontamentos com pendências e gerar todos os relatórios.',
icone: 'Zap',
badge: 'Liderança',
isCustom: false,
permissions: {
smart_of: { can_view: true, can_edit: true, can_delete: false },
smart_fases: { can_view: true, can_edit: true, can_delete: false },
smart_processos: { can_view: true, can_edit: true, can_delete: false },
smart_apontamento: { can_view: true, can_edit: true, can_delete: true },
smart_forcar_apontamento: { can_view: true, can_edit: true, can_delete: false },
smart_3d: { can_view: true, can_edit: false, can_delete: false },
smart_modo_completo: { can_view: true, can_edit: false, can_delete: false },
smart_romaneios: { can_view: true, can_edit: true, can_delete: false },
smart_montagem: { can_view: true, can_edit: true, can_delete: false },
smart_relatorios: { can_view: true, can_edit: true, can_delete: false },
},
},
{
id: 'inspetor_qualidade',
nome: 'Inspetor de Qualidade',
descricao: 'Acesso ao 3D, relatórios, romaneios e visualização completa de processos.',
icone: 'CheckCircle2',
badge: 'Qualidade',
isCustom: false,
permissions: {
smart_of: { can_view: true, can_edit: false, can_delete: false },
smart_fases: { can_view: true, can_edit: false, can_delete: false },
smart_processos: { can_view: true, can_edit: false, can_delete: false },
smart_apontamento: { can_view: true, can_edit: false, can_delete: false },
smart_forcar_apontamento: { can_view: false, can_edit: false, can_delete: false },
smart_3d: { can_view: true, can_edit: false, can_delete: false },
smart_modo_completo: { can_view: false, can_edit: false, can_delete: false },
smart_romaneios: { can_view: true, can_edit: false, can_delete: false },
smart_montagem: { can_view: true, can_edit: false, can_delete: false },
smart_relatorios: { can_view: true, can_edit: true, can_delete: false },
},
},
{
id: 'total_admin',
nome: 'Total Admin',
descricao: 'Acesso irrestrito a todos os submódulos, forçar apontamentos e modo completo.',
icone: 'Shield',
badge: 'Administrador',
isCustom: false,
permissions: {
smart_of: { can_view: true, can_edit: true, can_delete: true },
smart_fases: { can_view: true, can_edit: true, can_delete: true },
smart_processos: { can_view: true, can_edit: true, can_delete: true },
smart_apontamento: { can_view: true, can_edit: true, can_delete: true },
smart_forcar_apontamento: { can_view: true, can_edit: true, can_delete: true },
smart_3d: { can_view: true, can_edit: true, can_delete: true },
smart_modo_completo: { can_view: true, can_edit: true, can_delete: true },
smart_romaneios: { can_view: true, can_edit: true, can_delete: true },
smart_montagem: { can_view: true, can_edit: true, can_delete: true },
smart_relatorios: { can_view: true, can_edit: true, can_delete: true },
},
},
{
id: 'apenas_leitura',
nome: 'Apenas Leitura',
descricao: 'Somente visualização de OFs, peças, 3D e status sem permissão de apontamento.',
icone: 'Eye',
badge: 'Consulta',
isCustom: false,
permissions: {
smart_of: { can_view: true, can_edit: false, can_delete: false },
smart_fases: { can_view: true, can_edit: false, can_delete: false },
smart_processos: { can_view: true, can_edit: false, can_delete: false },
smart_apontamento: { can_view: false, can_edit: false, can_delete: false },
smart_forcar_apontamento: { can_view: false, can_edit: false, can_delete: false },
smart_3d: { can_view: true, can_edit: false, can_delete: false },
smart_modo_completo: { can_view: false, can_edit: false, can_delete: false },
smart_romaneios: { can_view: true, can_edit: false, can_delete: false },
smart_montagem: { can_view: true, can_edit: false, can_delete: false },
smart_relatorios: { can_view: true, can_edit: false, can_delete: false },
},
},
];
export interface SmartUserPermission {
id?: string;
user_id: string;
resource_key: string;
can_view: boolean;
can_edit: boolean;
can_delete: boolean;
}
export interface SmartUserConfig {
userId: string;
email: string;
fullName: string;
functionName: string;
profileImageUrl: string | null;
whatsappNumber: string;
notifEmailApontamentos: boolean;
notifEmailMetricas: boolean;
notifWhatsappApontamentos: boolean;
notifWhatsappMetricas: boolean;
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>;
}
@@ -0,0 +1,88 @@
-- ---------------------------------------------------------
-- TRACKSTEEL ERP: TABELA DE REVISÕES DE PEÇAS POR OF
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.revisoes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
of_number TEXT NOT NULL,
peca_id UUID NOT NULL REFERENCES public.pecas(id) ON DELETE CASCADE,
numero_revisao TEXT NOT NULL,
tipo_revisao TEXT NOT NULL CHECK (tipo_revisao IN ('simples', 'critica')),
descricao_motivo TEXT,
observacoes TEXT,
usuario_id TEXT,
usuario_nome TEXT,
usuario_email TEXT,
data_revisao TIMESTAMPTZ NOT NULL DEFAULT now(),
dados_anteriores JSONB NOT NULL DEFAULT '{}'::jsonb,
dados_novos JSONB NOT NULL DEFAULT '{}'::jsonb,
campos_alterados TEXT[] NOT NULL DEFAULT '{}',
afeta_apontamentos BOOLEAN NOT NULL DEFAULT false,
impacto_processos JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Índices
CREATE INDEX IF NOT EXISTS idx_revisoes_of_number ON public.revisoes(of_number);
CREATE INDEX IF NOT EXISTS idx_revisoes_peca_id ON public.revisoes(peca_id);
CREATE INDEX IF NOT EXISTS idx_revisoes_data ON public.revisoes(data_revisao DESC);
CREATE INDEX IF NOT EXISTS idx_revisoes_tipo ON public.revisoes(tipo_revisao);
-- Colunas auxiliares na tabela pecas
ALTER TABLE public.pecas
ADD COLUMN IF NOT EXISTS revisao_atual TEXT DEFAULT 'Rev 00',
ADD COLUMN IF NOT EXISTS tem_revisao BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS qtd_revisoes INTEGER DEFAULT 0;
-- Trigger para sincronizar campos de revisão na tabela pecas
CREATE OR REPLACE FUNCTION public.fn_sync_peca_revisoes()
RETURNS TRIGGER AS $$
DECLARE
v_peca_id UUID;
v_count INTEGER;
v_last_rev TEXT;
BEGIN
IF TG_OP = 'DELETE' THEN
v_peca_id := OLD.peca_id;
ELSE
v_peca_id := NEW.peca_id;
END IF;
SELECT count(*), COALESCE((SELECT numero_revisao FROM public.revisoes WHERE peca_id = v_peca_id ORDER BY data_revisao DESC, created_at DESC LIMIT 1), 'Rev 00')
INTO v_count, v_last_rev
FROM public.revisoes
WHERE peca_id = v_peca_id;
UPDATE public.pecas
SET tem_revisao = (v_count > 0),
qtd_revisoes = v_count,
revisao_atual = v_last_rev,
updated_at = now()
WHERE id = v_peca_id;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_sync_peca_revisoes ON public.revisoes;
CREATE TRIGGER trg_sync_peca_revisoes
AFTER INSERT OR UPDATE OR DELETE ON public.revisoes
FOR EACH ROW EXECUTE FUNCTION public.fn_sync_peca_revisoes();
-- Políticas RLS
ALTER TABLE public.revisoes ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "revisoes_select_policy" ON public.revisoes;
CREATE POLICY "revisoes_select_policy" ON public.revisoes FOR SELECT USING (true);
DROP POLICY IF EXISTS "revisoes_insert_policy" ON public.revisoes;
CREATE POLICY "revisoes_insert_policy" ON public.revisoes FOR INSERT WITH CHECK (true);
DROP POLICY IF EXISTS "revisoes_update_policy" ON public.revisoes;
CREATE POLICY "revisoes_update_policy" ON public.revisoes FOR UPDATE USING (true) WITH CHECK (true);
DROP POLICY IF EXISTS "revisoes_delete_policy" ON public.revisoes;
CREATE POLICY "revisoes_delete_policy" ON public.revisoes FOR DELETE USING (true);
GRANT ALL ON TABLE public.revisoes TO postgres, anon, authenticated, service_role;
+35 -20
View File
@@ -1,46 +1,61 @@
#!/bin/bash #!/bin/bash
# --------------------------------------------------------- # ---------------------------------------------------------
# TRACKSTEEL APP: DEPLOY E SINCRONIZAÇÃO # TRACKSTEEL APP: DEPLOY & SINCRONIZAÇÃO (MARCOS-VPS -> PROD & HOSTINGER)
# --------------------------------------------------------- # ---------------------------------------------------------
set -e
CYAN='\033[0;36m' CYAN='\033[0;36m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' NC='\033[0m'
echo -e "\n${CYAN}🚀 Iniciando Ciclo Automático de Deploy TrackSteelAPP...${NC}" echo -e "\n${CYAN}🚀 Iniciando Ciclo Automático de Deploy TrackSteel APP (marcos-vps)...${NC}"
echo -e "${YELLOW}📝 Sincronizando código...${NC}" # 1. Git Commit & Push
echo -e "\n${YELLOW}📝 Sincronizando código com o repositório GitHub...${NC}"
git add . git add .
if git diff-index --quiet HEAD --; then if git diff-index --quiet HEAD --; then
echo -e "${GREEN}✨ Nenhuma nova alteração para comitar.${NC}" echo -e "${GREEN}✨ Nenhuma nova alteração para comitar.${NC}"
else else
TIMESTAMP=$(date +"%d/%m/%Y %H:%M:%S") TIMESTAMP=$(date +"%d/%m/%Y %H:%M:%S")
MSG="${1:-🚀 Auto-deploy: TrackSteelAPP atualizado em $TIMESTAMP}" MSG="${1:-🚀 Auto-deploy: TrackSteel APP atualizado em $TIMESTAMP}"
echo -e "${CYAN}📤 Criando novo commit: $MSG${NC}" echo -e "${CYAN}📤 Criando novo commit: $MSG${NC}"
git commit -m "$MSG" git commit -m "$MSG"
fi fi
echo -e "${CYAN}📤 Sincronizando com os repositórios remotos (Gitea e GitHub)...${NC}" echo -e "${CYAN}📤 Enviando alterações para o GitHub (origin main)...${NC}"
git push origin main git push origin main
echo -e "\n${YELLOW}🔄 Disparando Deploy via API Oficial no Coolify (Hostinger)...${NC}" # 2. Build local do Frontend
echo -e "\n${YELLOW}🔨 Executando build de produção local (npm run build)...${NC}"
npm run build
# 3. Reiniciar container do Tracksteel local
echo -e "\n${YELLOW}🔄 Atualizando container local tracksteel-app...${NC}"
if docker ps -a --format '{{.Names}}' | grep -q "^tracksteel-app$"; then
docker restart tracksteel-app
echo -e "${GREEN}✅ Container tracksteel-app reiniciado com sucesso!${NC}"
else
echo -e "${YELLOW}ℹ️ Container tracksteel-app não encontrado no docker local.${NC}"
fi
# 4. Replicação Reversa de Banco de Dados para a Hostinger
echo -e "\n${YELLOW}🗄️ Executando replicação reversa de BD e Storage para a Hostinger...${NC}"
SCRIPT_SYNC="/root/Apps/TracksteelApp/scripts/sync_db_to_hostinger.sh"
if [ -f "$SCRIPT_SYNC" ]; then
chmod +x "$SCRIPT_SYNC"
"$SCRIPT_SYNC" || echo -e "${YELLOW}⚠️ Aviso: A replicação para Hostinger apresentou avisos ou falha de conexão.${NC}"
fi
# 5. Notificar Coolify da Hostinger para deploy de contingência
echo -e "\n${YELLOW}🌐 Atualizando app réplica no Coolify da Hostinger...${NC}"
COOLIFY_TOKEN="19|ZJKUbBgyeNGcKrVkMI5gJ1uMHDC39d9VIShHL473a60d9882" COOLIFY_TOKEN="19|ZJKUbBgyeNGcKrVkMI5gJ1uMHDC39d9VIShHL473a60d9882"
TRACKSTEEL_UUID="i8o44gggg00o88ccc8oo48kk" TRACKSTEEL_UUID="i8o44gggg00o88ccc8oo48kk"
curl -s -X GET "https://painel.reifonas.cloud/api/v1/deploy?uuid=${TRACKSTEEL_UUID}&force=false" \ curl -s -X GET "https://painel.reifonas.cloud/api/v1/deploy?uuid=${TRACKSTEEL_UUID}&force=false" \
-H "Authorization: Bearer $COOLIFY_TOKEN" -H "Authorization: Bearer $COOLIFY_TOKEN" >/dev/null 2>&1 || true
echo -e "\n${GREEN}🚀 Deploy local (Hostinger) engatilhado com sucesso!${NC}" echo -e "${GREEN}🚀 Deploy da réplica (Hostinger) acionado com sucesso!${NC}"
echo -e "\n${YELLOW}🌐 Sincronizando código e atualizando app na marcos-vps (100.97.2.16)...${NC}" echo -e "\n${GREEN}🏁 Ciclo de deploy e replicação concluído com sucesso!${NC}\n"
ssh -o BatchMode=yes -o ConnectTimeout=5 root@100.97.2.16 \
"cd /root/Apps/TracksteelApp && git pull origin main && npm run build && docker restart tracksteel-app" \
&& echo -e "${GREEN}✅ App na marcos-vps atualizado com sucesso!${NC}" \
|| echo -e "${YELLOW}⚠️ Aviso: Falha ao atualizar app na marcos-vps via SSH.${NC}"
echo -e "\n${YELLOW}🗄️ Replicando Banco de Dados e Storage para marcos-vps...${NC}"
if [ -f "/root/Apps/TrackSteelAPP/scripts/sync_db_to_marcos.sh" ]; then
/root/Apps/TrackSteelAPP/scripts/sync_db_to_marcos.sh
fi
echo -e "\n${GREEN}🏁 Ciclo completo de deploy e replicação concluído com sucesso!${NC}\n"