Compare commits
20 Commits
f50aad1926
...
fdec79f81b
| Author | SHA1 | Date | |
|---|---|---|---|
| fdec79f81b | |||
| 9cbc9b383e | |||
| a8275bda8c | |||
| 885a076122 | |||
| 29a4800ff5 | |||
| fbdf7e379e | |||
| 7f4afc5e61 | |||
| f42976a09d | |||
| d177184a62 | |||
| 161f73edd6 | |||
| 28c23a7324 | |||
| c239dffdee | |||
| 8755852fbd | |||
| e537d731f3 | |||
| 36d5902836 | |||
| 8e64ad6042 | |||
| 81ba4e8e3e | |||
| 6f595d6839 | |||
| 246e8d08f2 | |||
| 8847c93ac2 |
@@ -697,7 +697,8 @@ R: Sim, Supabase fornece PostgREST automático. Pode integrar com qualquer siste
|
||||
|
||||
## 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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Executable
+117
@@ -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
|
||||
@@ -59,6 +59,8 @@ export function AppSidebar() {
|
||||
'estoque': '#8b5cf6', // roxo
|
||||
'ofs': '#eab308', // amarelo
|
||||
'producao': '#ef4444', // vermelho
|
||||
'visualizador-3d': '#8b5cf6', // roxo
|
||||
'analisador-3d': '#6366f1', // indigo
|
||||
'painel-industrial': '#06b6d4', // ciano
|
||||
'expedicao': '#ec4899', // rosa
|
||||
'obra': '#a3a3a3', // marrom
|
||||
@@ -100,46 +102,67 @@ export function AppSidebar() {
|
||||
// Admin can always access everything
|
||||
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> = {
|
||||
// PCP & Produção
|
||||
'pcp': 'producao-pcp',
|
||||
'dashboard-producao': 'producao-dashboard',
|
||||
'apontamento-producao': 'producao-apontamento',
|
||||
'diario-producao': 'diario-producao',
|
||||
'prioridades-fabricacao': 'prioridades-fabricacao',
|
||||
'visao-geral': 'producao-visao',
|
||||
'painel-industrial': 'producao-dashboard',
|
||||
// Engenharia, 3D & OFs
|
||||
'visualizador-3d': 'visualizador-3d',
|
||||
'analisador-3d': 'analisador-3d',
|
||||
'ordens-fabricacao': 'ofs-lista',
|
||||
'ofs-concluidas': 'ofs-lista',
|
||||
'ficha-tecnica-of': 'cadastro-of',
|
||||
'cronograma': 'ofs-cronograma',
|
||||
'cadastro-pecas': 'cadastro-pecas',
|
||||
'equipamentos': 'equipamentos',
|
||||
// Expedição & Estoque
|
||||
'expedicao': 'expedicao',
|
||||
'estoque-main': 'estoque',
|
||||
'estoque': 'estoque',
|
||||
'solicitacao-compras': 'estoque-solicitacao-compras',
|
||||
// Obra & Campo
|
||||
'dashboard-obras': 'obra-dashboard',
|
||||
'configuracoes-obra': 'obra-configuracoes',
|
||||
'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',
|
||||
'user-management': 'user-management',
|
||||
'configuracoes-gerais': 'configuracoes-gerais',
|
||||
'inconsistencias': 'ferramentas-inconsistencias',
|
||||
'ferramentas': 'ferramentas',
|
||||
'sistema': 'sistema',
|
||||
'sugestoes': 'sugestoes',
|
||||
'personalizacao-tema': 'admin',
|
||||
'admin': 'admin',
|
||||
'dashboard': 'dashboard',
|
||||
};
|
||||
|
||||
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]) {
|
||||
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 =
|
||||
resourcePermissions &&
|
||||
resourcePermissions['modo-smart'] &&
|
||||
@@ -156,8 +179,23 @@ export function AppSidebar() {
|
||||
if (soTemSmart && !resKey.startsWith('smart') && resKey !== 'modo-smart') {
|
||||
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 (itemKey === 'ferramentas') {
|
||||
return canAccessTools();
|
||||
@@ -235,12 +273,25 @@ export function AppSidebar() {
|
||||
{menuGroups.map(group => {
|
||||
// Filter admin groups for non-admin users
|
||||
if (!isAdmin && group.name === 'Administração') {
|
||||
logger.debug('Ocultando grupo admin para usuário não-admin');
|
||||
return null;
|
||||
const hasAnyAdminItem = group.items.some(item => canAccessItem(item.key, item.requiresSpecialPermission));
|
||||
if (!hasAnyAdminItem) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip groups with no items
|
||||
if (!group.items || group.items.length === 0) {
|
||||
// Filtrar itens do grupo permitidos para o usuário logado
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -254,7 +305,7 @@ export function AppSidebar() {
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => (
|
||||
{itemsPermitidos.map((item) => (
|
||||
<AppSidebarMenuItem
|
||||
key={item.key || item.title}
|
||||
item={item}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { usePecas } from '@/hooks/usePecas';
|
||||
|
||||
export interface BocadConvertedPiece {
|
||||
of: string;
|
||||
@@ -39,6 +40,7 @@ export interface BocadConvertedPiece {
|
||||
perfilPrincipal: string;
|
||||
comprimentoRef: number | string;
|
||||
qtdComponentesFilhos: number;
|
||||
components?: RawBocadComponent[];
|
||||
}
|
||||
|
||||
interface RawBocadComponent {
|
||||
@@ -66,8 +68,10 @@ interface RawBocadAssembly {
|
||||
}
|
||||
|
||||
export const BocadConverter: React.FC = () => {
|
||||
const { importPecas } = usePecas();
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isDirectImporting, setIsDirectImporting] = useState(false);
|
||||
const [tratamentoGlobal, setTratamentoGlobal] = useState<'pintura' | 'galvanizacao'>('pintura');
|
||||
const [extractedPieces, setExtractedPieces] = useState<BocadConvertedPiece[]>([]);
|
||||
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):
|
||||
// No formato Bocad:
|
||||
// 1. Linha principal tem 'Marca' preenchida, 'Quant' > 0, 'Perfil' genérico (ex: VIGA)
|
||||
// 2. E o campo 'Qualid' é VAZIO na linha principal (o aço fica nas linhas de componentes)
|
||||
// 3. O Peso Total é a somatória do lote da peça
|
||||
const isMainAssemblyRow = !qualidVal && marcaVal && quantVal > 0 && perfilVal;
|
||||
// Pela regra do Bocad: Marcas < 1000 são as peças principais (linhas cinzas)
|
||||
// Marcas >= 1000 são componentes (linhas brancas) que ficam abaixo da peça principal
|
||||
const marcaNum = parseInt(marcaVal, 10);
|
||||
const isMainAssemblyRow = !isNaN(marcaNum) && marcaNum < 1000 && marcaVal && quantVal > 0;
|
||||
|
||||
if (isMainAssemblyRow) {
|
||||
// Se já havia um subconjunto sendo processado, salva-o
|
||||
@@ -411,7 +414,8 @@ export const BocadConverter: React.FC = () => {
|
||||
material: normalizedMat,
|
||||
perfilPrincipal: perfilPrincipal,
|
||||
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 totalPecasCompostas = extractedPieces.filter((p) => p.compostoPorComponentes === 'SIM').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">
|
||||
<Button
|
||||
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" />
|
||||
Baixar Planilha Padrão (.xlsx)
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import React from 'react';
|
||||
import { Table, TableBody, TableHeader } from '@/components/ui/table';
|
||||
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 { usePecasTable } from '@/hooks/usePecasTable';
|
||||
import { TableFilters } from './table/TableFilters';
|
||||
@@ -22,6 +23,9 @@ interface PecasTableProps {
|
||||
onEditPeca?: (peca: Peca) => void;
|
||||
onDeleteLastImport?: () => void;
|
||||
onBatchUpdatePecas?: (pecaIds: string[], updates: any) => Promise<void>;
|
||||
onOpenRevisaoPeca?: (peca: Peca) => void;
|
||||
onOpenPainelRevisoes?: () => void;
|
||||
totalRevisoesOF?: number;
|
||||
hasRecentImport: boolean;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
@@ -36,6 +40,9 @@ export function PecasTable({
|
||||
onEditPeca,
|
||||
onDeleteLastImport,
|
||||
onBatchUpdatePecas,
|
||||
onOpenRevisaoPeca,
|
||||
onOpenPainelRevisoes,
|
||||
totalRevisoesOF,
|
||||
hasRecentImport,
|
||||
canEdit = true,
|
||||
canDelete = true,
|
||||
@@ -169,6 +176,25 @@ export function PecasTable({
|
||||
/>
|
||||
|
||||
<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 && (
|
||||
<BatchEditButton
|
||||
selectedPecas={selectedPecas}
|
||||
@@ -209,6 +235,7 @@ export function PecasTable({
|
||||
onEdit={() => handleEditPeca(peca)}
|
||||
onDelete={() => handleDeletePeca(peca.id)}
|
||||
onOpenComponentPopup={() => onOpenComponentPopup(peca.id)}
|
||||
onOpenRevisao={() => onOpenRevisaoPeca?.(peca)}
|
||||
/>
|
||||
))}
|
||||
</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 "Nova Revisão", 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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
Layers,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Minus
|
||||
Minus,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { Peca } from '@/hooks/usePecas';
|
||||
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
|
||||
@@ -28,6 +29,7 @@ interface TableRowProps {
|
||||
onOpenComponentPopup: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onOpenRevisao?: () => void;
|
||||
}
|
||||
|
||||
export function TableRow({
|
||||
@@ -36,13 +38,15 @@ export function TableRow({
|
||||
onSelect,
|
||||
onOpenComponentPopup,
|
||||
onEdit,
|
||||
onDelete
|
||||
onDelete,
|
||||
onOpenRevisao
|
||||
}: TableRowProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { labels } = useAppLabels();
|
||||
const { componentes, loading } = useComponentesPeca(isExpanded ? peca.id : '');
|
||||
|
||||
const hasSubComponents = peca.tem_componentes;
|
||||
const hasRevisao = Boolean(peca.tem_revisao || (peca.qtd_revisoes && peca.qtd_revisoes > 0));
|
||||
|
||||
const toggleExpand = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -88,9 +92,26 @@ export function TableRow({
|
||||
{peca.etapa_fase}
|
||||
</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">
|
||||
<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 && (
|
||||
<span
|
||||
onClick={toggleExpand}
|
||||
@@ -168,6 +189,18 @@ export function TableRow({
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
</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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -27,12 +27,6 @@ export const AppSidebarMenuItem: React.FC<SidebarMenuItemProps> = ({
|
||||
onMenuItemClick,
|
||||
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
|
||||
const subItemsPermitidos = (item.subItems || []).filter((subItem) => {
|
||||
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
|
||||
if (item.subItems && item.subItems.length > 0 && subItemsPermitidos.length === 0 && !isAdmin) {
|
||||
return null;
|
||||
if (item.subItems && item.subItems.length > 0) {
|
||||
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 (
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
Settings,
|
||||
Shield,
|
||||
UserCog,
|
||||
Cog
|
||||
Cog,
|
||||
ScanSearch
|
||||
} from "lucide-react";
|
||||
import { MenuItem, MenuGroup } from './types';
|
||||
|
||||
@@ -156,6 +157,12 @@ export const menuGroups: MenuGroup[] = [
|
||||
url: "/visualizador-3d",
|
||||
icon: Box,
|
||||
},
|
||||
{
|
||||
key: "analisador-3d",
|
||||
title: "Analisador 3D",
|
||||
url: "/analisador-3d",
|
||||
icon: ScanSearch,
|
||||
},
|
||||
{
|
||||
key: "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 { MatrizGrid } from './MatrizGrid';
|
||||
import { MatrizTemplatesModal } from './MatrizTemplatesModal';
|
||||
import { MatrizSmartAvancadoModal } from './MatrizSmartAvancadoModal';
|
||||
import { MatrizGrupoAvancadoModal } from './MatrizGrupoAvancadoModal';
|
||||
import { SimuladorVisaoBar } from './SimuladorVisaoBar';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 {
|
||||
onOpenNovoUsuario?: () => void;
|
||||
@@ -28,6 +30,8 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
|
||||
|
||||
const [busca, setBusca] = useState('');
|
||||
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
|
||||
const usuariosFiltrados = usuarios.filter((u) => {
|
||||
@@ -105,6 +109,16 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
|
||||
</div>
|
||||
|
||||
<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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -139,6 +153,8 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
|
||||
grupos={gruposModulos}
|
||||
onToggleNivel={toggleNivel}
|
||||
onOpenTemplates={(user) => setSelectedUserForTemplate(user)}
|
||||
onOpenSmartAvancado={() => setSmartAvancadoOpen(true)}
|
||||
onOpenGrupoAvancado={(key) => setGrupoAvancadoKey(key)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -149,6 +165,19 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
|
||||
onClose={() => setSelectedUserForTemplate(null)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { UsuarioMatriz, GrupoRecurso, NivelAcesso } from '@/hooks/useMatrizAcess
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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';
|
||||
|
||||
interface MatrizGridProps {
|
||||
@@ -11,6 +11,8 @@ interface MatrizGridProps {
|
||||
grupos: GrupoRecurso[];
|
||||
onToggleNivel: (userId: string, resourceKey: string, tipo: 'V' | 'C') => void;
|
||||
onOpenTemplates: (user: UsuarioMatriz) => void;
|
||||
onOpenSmartAvancado?: () => void;
|
||||
onOpenGrupoAvancado?: (grupoKey: string) => void;
|
||||
}
|
||||
|
||||
export const MatrizGrid: React.FC<MatrizGridProps> = ({
|
||||
@@ -18,6 +20,8 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
|
||||
grupos,
|
||||
onToggleNivel,
|
||||
onOpenTemplates,
|
||||
onOpenSmartAvancado,
|
||||
onOpenGrupoAvancado,
|
||||
}) => {
|
||||
// Cores de fundo e borda para os grupos
|
||||
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',
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="rounded-2xl border border-border bg-card overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto max-w-full">
|
||||
@@ -48,8 +61,31 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
|
||||
colSpan={grupo.recursos.length}
|
||||
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>
|
||||
{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>
|
||||
<span className="text-[10px] font-normal opacity-80 block truncate">
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { ViewCube } from './ViewCube';
|
||||
import { Viewer3DSidebar } from './Viewer3DSidebar';
|
||||
import { PieceHoverTooltip } from './PieceHoverTooltip';
|
||||
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 { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
@@ -20,6 +20,53 @@ interface ProductionPieceStatus {
|
||||
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 {
|
||||
modelData: LoadedIFCResult | null;
|
||||
productionData?: Map<string, ProductionPieceStatus>;
|
||||
@@ -59,6 +106,13 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
const [colorMode, setColorMode] = useState<'description' | 'production'>('production');
|
||||
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)
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [hoveredPiece, setHoveredPiece] = useState<PieceInfo | null>(null);
|
||||
@@ -161,18 +215,22 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
// 5. Lights
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
|
||||
scene.add(ambientLight);
|
||||
ambientLightRef.current = ambientLight;
|
||||
|
||||
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
dirLight1.position.set(100, 200, 100);
|
||||
dirLight1.castShadow = true;
|
||||
scene.add(dirLight1);
|
||||
dirLight1Ref.current = dirLight1;
|
||||
|
||||
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6);
|
||||
dirLight2.position.set(-100, -50, -100);
|
||||
scene.add(dirLight2);
|
||||
dirLight2Ref.current = dirLight2;
|
||||
|
||||
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x1e293b, 0.5);
|
||||
scene.add(hemiLight);
|
||||
hemiLightRef.current = hemiLight;
|
||||
|
||||
// 6. Floor Grid (Cores refinadas e ultrafinas para modo claro e escuro)
|
||||
const updateGridTheme = (gridHelper: THREE.GridHelper) => {
|
||||
@@ -278,6 +336,16 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false);
|
||||
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
|
||||
const fitModelToView = useCallback((group?: THREE.Group | null) => {
|
||||
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
|
||||
const ifcId = mesh.userData.ifcId;
|
||||
const ifcId = mesh.userData.ifcId ?? mesh.userData.expressID;
|
||||
if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) {
|
||||
mesh.userData.originalMaterial = mesh.material;
|
||||
mesh.material = magentaMaterialRef.current;
|
||||
@@ -868,17 +936,21 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
}
|
||||
|
||||
if (hitMesh) {
|
||||
const expressID = hitMesh.userData.ifcId;
|
||||
const expressID = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
|
||||
const pmark = hitMesh.userData.pieceMark;
|
||||
|
||||
if (modelData && expressID !== undefined) {
|
||||
const piece = modelData.pieceByExpressID.get(expressID) || {
|
||||
expressID,
|
||||
guid: '',
|
||||
name: hitMesh.userData.section || 'Peça',
|
||||
type: 'PIECE',
|
||||
guid: hitMesh.userData.guid || '',
|
||||
name: hitMesh.userData.name || hitMesh.userData.section || 'Peça',
|
||||
type: hitMesh.userData.type || 'PIECE',
|
||||
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);
|
||||
setHoverPosition({ x: clientX, y: clientY });
|
||||
@@ -935,7 +1007,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
}
|
||||
|
||||
if (hitMesh && hitMesh.visible) {
|
||||
const targetIfcId = hitMesh.userData.ifcId;
|
||||
const targetIfcId = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
|
||||
if (targetIfcId === undefined) return;
|
||||
|
||||
const nextMap = new Map(selectedElementsMapRef.current);
|
||||
@@ -951,16 +1023,19 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
});
|
||||
nextMap.delete(targetIfcId);
|
||||
} 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[] = [];
|
||||
if (modelGroupRef.current) {
|
||||
modelGroupRef.current.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh && child.userData.ifcId === targetIfcId) {
|
||||
const m = child as THREE.Mesh;
|
||||
m.userData.originalMaterial = m.material;
|
||||
m.userData.isMagentaSelected = true;
|
||||
m.material = magentaMaterialRef.current;
|
||||
relatedMeshes.push(m);
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const childId = child.userData.ifcId ?? child.userData.expressID;
|
||||
if (childId === targetIfcId) {
|
||||
const m = child as THREE.Mesh;
|
||||
m.userData.originalMaterial = m.material;
|
||||
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 piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || {
|
||||
expressID: targetIfcId,
|
||||
guid: '',
|
||||
name: hitMesh.userData.section || 'Peça',
|
||||
type: 'PIECE',
|
||||
guid: hitMesh.userData.guid || '',
|
||||
name: hitMesh.userData.name || hitMesh.userData.section || 'Peça',
|
||||
type: hitMesh.userData.type || 'PIECE',
|
||||
pieceMark: pmark,
|
||||
cleanMark: hitMesh.userData.cleanMark,
|
||||
assemblyMark: hitMesh.userData.assemblyMark,
|
||||
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark,
|
||||
section: hitMesh.userData.section,
|
||||
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 mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
raycasterRef.current.setFromCamera(new THREE.Vector2(mouseX, mouseY), activeCameraRef.current);
|
||||
const intersects = raycasterRef.current.intersectObjects(modelGroupRef.current.children, true);
|
||||
const raycaster = raycasterRef.current;
|
||||
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) {
|
||||
const hitPoint = intersects[0].point;
|
||||
@@ -1012,36 +1114,49 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
|
||||
if (hitMesh) {
|
||||
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() : '';
|
||||
|
||||
if (targetPieceMark) {
|
||||
if (targetPieceMark || targetCleanMark) {
|
||||
const nextMap = new Map(selectedElementsMapRef.current);
|
||||
|
||||
// Agrupa todas as sub-malhas por elemento físico (ifcId único)
|
||||
const meshesByIfcId = new Map<number, { meshes: THREE.Mesh[]; sampleMesh: THREE.Mesh }>();
|
||||
// Agrupa todas as sub-malhas por elemento físico único (ifcId ?? expressID)
|
||||
const meshesByElementId = new Map<number, { meshes: THREE.Mesh[]; sampleMesh: THREE.Mesh }>();
|
||||
|
||||
modelGroupRef.current.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh && child.visible) {
|
||||
const mesh = child as THREE.Mesh;
|
||||
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() : '';
|
||||
|
||||
// Mesma marca e mesma fase
|
||||
const isMatch = pmark === targetPieceMark && (!targetPhase || !phase || phase === targetPhase);
|
||||
if (isMatch) {
|
||||
const ifcId = mesh.userData.ifcId;
|
||||
if (ifcId !== undefined) {
|
||||
if (!meshesByIfcId.has(ifcId)) {
|
||||
meshesByIfcId.set(ifcId, { meshes: [], sampleMesh: mesh });
|
||||
// Mesma marca (peça, conjunto ou limpa) e mesma fase (se especificada)
|
||||
const isMarkMatch =
|
||||
(pmark && pmark === targetPieceMark) ||
|
||||
(cmark && cmark === targetCleanMark) ||
|
||||
(targetAssemblyMark && amark === targetAssemblyMark) ||
|
||||
(targetCleanAssMark && cleanAm === targetCleanAssMark);
|
||||
|
||||
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
|
||||
meshesByIfcId.forEach(({ meshes, sampleMesh }, ifcId) => {
|
||||
meshesByElementId.forEach(({ meshes, sampleMesh }, elId) => {
|
||||
meshes.forEach((m) => {
|
||||
if (!m.userData.isMagentaSelected) {
|
||||
m.userData.originalMaterial = m.material;
|
||||
@@ -1050,18 +1165,21 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
m.material = magentaMaterialRef.current;
|
||||
});
|
||||
|
||||
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(ifcId)) || {
|
||||
expressID: ifcId,
|
||||
guid: '',
|
||||
name: sampleMesh.userData.section || 'Peça',
|
||||
type: 'PIECE',
|
||||
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(elId)) || {
|
||||
expressID: elId,
|
||||
guid: sampleMesh.userData.guid || '',
|
||||
name: sampleMesh.userData.name || sampleMesh.userData.section || 'Peça',
|
||||
type: sampleMesh.userData.type || 'PIECE',
|
||||
pieceMark: sampleMesh.userData.pieceMark,
|
||||
cleanMark: sampleMesh.userData.cleanMark,
|
||||
assemblyMark: sampleMesh.userData.assemblyMark,
|
||||
cleanAssemblyMark: sampleMesh.userData.cleanAssemblyMark,
|
||||
section: sampleMesh.userData.section,
|
||||
phase: sampleMesh.userData.phase || targetPhase,
|
||||
};
|
||||
|
||||
nextMap.set(ifcId, {
|
||||
expressID: ifcId,
|
||||
nextMap.set(elId, {
|
||||
expressID: elId,
|
||||
piece,
|
||||
meshes,
|
||||
});
|
||||
@@ -1097,7 +1215,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
const selectedPiecesList: SelectedPieceItem[] = Array.from(selectedElementsMap.values()).map(
|
||||
({ expressID, piece }) => {
|
||||
const pmark = piece.pieceMark || 'Sem Marca';
|
||||
const prod = productionData?.get(pmark);
|
||||
const prod = findProductionDataForPiece(productionData, piece, selectedOF);
|
||||
return {
|
||||
id: String(expressID),
|
||||
pieceMark: pmark,
|
||||
@@ -1142,20 +1260,58 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
onSelectProcess={setSelectedProcess}
|
||||
onFitView={() => fitModelToView()}
|
||||
onToggleFullscreen={handleToggleFullscreen}
|
||||
brightness={brightness}
|
||||
onBrightnessChange={setBrightness}
|
||||
/>
|
||||
|
||||
{/* Top Right: ViewCube + 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">
|
||||
<ViewCube onSelectView={handleViewCubeSelect} mainCameraRef={activeCameraRef} />
|
||||
{/* 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-stretch gap-2 pointer-events-auto w-[140px]">
|
||||
{/* 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 */}
|
||||
<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
|
||||
? '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'
|
||||
}`}
|
||||
style={{ minWidth: '130px' }}
|
||||
>
|
||||
{/* Badge Indicador de Seleção Ativa Sóbrio */}
|
||||
{selectedCount > 0 && (
|
||||
@@ -1167,11 +1323,11 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botão Gerar Lista da Seleção (Azul Escuro no claro / Azul Elegante no escuro) */}
|
||||
{/* Botão Gerar Lista da Seleção */}
|
||||
<button
|
||||
onClick={() => setIsSelectionModalOpen(true)}
|
||||
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
|
||||
? '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'
|
||||
@@ -1186,11 +1342,11 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
<span className="whitespace-nowrap">Gerar lista</span>
|
||||
</button>
|
||||
|
||||
{/* Botão Limpar Seleção Harmonizado */}
|
||||
{/* Botão Limpar Seleção */}
|
||||
<button
|
||||
onClick={handleClearSelection}
|
||||
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
|
||||
? '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'
|
||||
@@ -1203,24 +1359,21 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 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' }}
|
||||
>
|
||||
<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">
|
||||
{/* 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 ${
|
||||
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
|
||||
? '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" />
|
||||
<RotateCw className="w-3 h-3 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||
<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}°
|
||||
</span>
|
||||
</button>
|
||||
@@ -1229,7 +1382,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
<button
|
||||
onClick={handleToggleLockRotation}
|
||||
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
|
||||
? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40'
|
||||
: isRotationLocked
|
||||
@@ -1243,28 +1396,56 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
}
|
||||
>
|
||||
{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>
|
||||
</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>
|
||||
|
||||
{/* Hover Tooltip (1.0s delay trigger) */}
|
||||
<PieceHoverTooltip
|
||||
piece={hoveredPiece}
|
||||
position={hoverPosition}
|
||||
productionInfo={
|
||||
hoveredPiece?.pieceMark && productionData
|
||||
productionInfo={(() => {
|
||||
const prod = findProductionDataForPiece(productionData, hoveredPiece, selectedOF);
|
||||
return prod
|
||||
? {
|
||||
pointedQty: productionData.get(hoveredPiece.pieceMark)?.pointedQtd,
|
||||
totalQty: productionData.get(hoveredPiece.pieceMark)?.totalQtd,
|
||||
currentStage: productionData.get(hoveredPiece.pieceMark)?.currentProcessName,
|
||||
stageColor: productionData.get(hoveredPiece.pieceMark)?.processColor,
|
||||
pointedQty: prod.pointedQtd,
|
||||
totalQty: prod.totalQtd,
|
||||
currentStage: prod.currentProcessName,
|
||||
stageColor: prod.processColor,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: undefined;
|
||||
})()}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
@@ -557,7 +557,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
mesh.userData.originalMaterial = mesh.material;
|
||||
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;
|
||||
|
||||
if (hitMesh && hitMesh.visible) {
|
||||
const targetIfcId = hitMesh.userData.ifcId;
|
||||
const targetIfcId = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
|
||||
if (targetIfcId === undefined) return;
|
||||
|
||||
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
|
||||
const relatedMeshes: THREE.Mesh[] = [];
|
||||
modelGroupRef.current.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh && child.userData.ifcId === targetIfcId) {
|
||||
const m = child as THREE.Mesh;
|
||||
m.userData.originalMaterial = m.material;
|
||||
m.material = magentaMaterialRef.current;
|
||||
relatedMeshes.push(m);
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const childId = child.userData.ifcId ?? child.userData.expressID;
|
||||
if (childId === targetIfcId) {
|
||||
const m = child as THREE.Mesh;
|
||||
m.userData.originalMaterial = m.material;
|
||||
m.material = magentaMaterialRef.current;
|
||||
relatedMeshes.push(m);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
Wrench,
|
||||
Paintbrush,
|
||||
Truck,
|
||||
Scissors
|
||||
Scissors,
|
||||
SunMedium
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Viewer3DSidebarProps {
|
||||
@@ -32,6 +33,8 @@ interface Viewer3DSidebarProps {
|
||||
onSelectProcess?: (proc: string) => void;
|
||||
onFitView: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
brightness?: number;
|
||||
onBrightnessChange?: (val: number) => void;
|
||||
}
|
||||
|
||||
const FABRICATION_PROCESSES = [
|
||||
@@ -56,6 +59,8 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
|
||||
onSelectProcess,
|
||||
onFitView,
|
||||
onToggleFullscreen,
|
||||
brightness = 100,
|
||||
onBrightnessChange,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(true);
|
||||
|
||||
@@ -64,36 +69,36 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
|
||||
{/* Container Principal da Barra Lateral */}
|
||||
<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 ${
|
||||
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 */}
|
||||
<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 gap-2">
|
||||
<Sliders className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-800 dark:text-slate-200">
|
||||
{/* Cabeçalho do Sub-Menu Enxuto */}
|
||||
<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-1.5">
|
||||
<Sliders className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-800 dark:text-slate-200">
|
||||
Controles 3D
|
||||
</span>
|
||||
</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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Corpo com Rolagem Suave */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4 custom-scrollbar">
|
||||
{/* Grupo 1: Destaque por Processo Industrial com Cores Correspondentes */}
|
||||
<div className="space-y-1.5">
|
||||
{/* Corpo Compactado com Rolagem Suave */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
|
||||
{/* Grupo 1: Destaque por Processo Industrial */}
|
||||
<div className="space-y-1">
|
||||
<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">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
Destaque por Processo
|
||||
<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 h-3 text-emerald-600 dark:text-emerald-400" />
|
||||
Processo Fabril
|
||||
</label>
|
||||
<span className="text-[9px] font-mono text-emerald-600 dark:text-emerald-400 font-bold">
|
||||
BIM + Fábrica
|
||||
<span className="text-[8px] font-mono text-emerald-600 dark:text-emerald-400 font-bold">
|
||||
BIM
|
||||
</span>
|
||||
</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) => {
|
||||
const Icon = proc.icon;
|
||||
const isSelected = selectedProcess === proc.id;
|
||||
@@ -106,24 +111,22 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
|
||||
? {
|
||||
backgroundColor: proc.color,
|
||||
color: '#ffffff',
|
||||
boxShadow: `0 4px 12px ${proc.color}50`,
|
||||
boxShadow: `0 2px 8px ${proc.color}40`,
|
||||
borderColor: proc.color,
|
||||
}
|
||||
: {
|
||||
backgroundColor: `${proc.color}18`,
|
||||
backgroundColor: `${proc.color}14`,
|
||||
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] ${
|
||||
isSelected
|
||||
? 'shadow-md border-transparent font-extrabold'
|
||||
: 'hover:brightness-110'
|
||||
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 ? '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]" />
|
||||
<span className="tracking-tight">{proc.label}</span>
|
||||
<Icon className="w-3 h-3 stroke-[2.2]" />
|
||||
<span className="tracking-tight leading-none">{proc.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -131,99 +134,93 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Grupo 2: Modo de Cores Geral */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5">
|
||||
<Palette className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
|
||||
<div className="space-y-1">
|
||||
<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 h-3 text-cyan-600 dark:text-cyan-400" />
|
||||
Modo de Cores
|
||||
</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
|
||||
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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
<span>Produção</span>
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<Box className="w-3.5 h-3.5" />
|
||||
<Box className="w-3 h-3" />
|
||||
<span>Padrão</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grupo 3: Ambiente & Grade */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5">
|
||||
<GridIcon className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
|
||||
<div className="space-y-1">
|
||||
<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 h-3 text-cyan-600 dark:text-cyan-400" />
|
||||
Ambiente & Piso
|
||||
</label>
|
||||
<div>
|
||||
{/* Toggle Grid */}
|
||||
<button
|
||||
onClick={onToggleGrid}
|
||||
className={`w-full flex items-center justify-center gap-2 py-2 rounded-xl text-xs font-semibold border transition-all ${
|
||||
showGrid
|
||||
? '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.5 h-3.5" />
|
||||
<span>Grade de Piso: {showGrid ? 'LIGADO' : 'DESL'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleGrid}
|
||||
className={`w-full flex items-center justify-center gap-1.5 py-1 rounded-lg text-[11px] font-semibold border transition-all ${
|
||||
showGrid
|
||||
? 'bg-cyan-50 dark:bg-cyan-950/40 text-cyan-800 dark:text-cyan-300 border-cyan-300 dark:border-cyan-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'
|
||||
}`}
|
||||
>
|
||||
<GridIcon className="w-3 h-3" />
|
||||
<span>Grade de Piso: {showGrid ? 'LIGADO' : 'DESL'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grupo 4: Câmera & Projeção */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5">
|
||||
<Compass className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
|
||||
{/* Grupo 4: Câmera & Visualização */}
|
||||
<div className="space-y-1">
|
||||
<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 h-3 text-cyan-600 dark:text-cyan-400" />
|
||||
Câmera & Visualização
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* Câmera Toggle */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
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
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<Box className="w-3.5 h-3.5" />
|
||||
<Box className="w-3 h-3" />
|
||||
<span>{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span>
|
||||
</button>
|
||||
|
||||
{/* Enquadrar / Reset View */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grupo 5: Opacidade da Estrutura */}
|
||||
<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="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
|
||||
<Eye className="w-3.5 h-3.5 text-amber-500" />
|
||||
{/* Grupo 5: Opacidade da Estrutura Compacta */}
|
||||
<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-[11px]">
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
|
||||
<Eye className="w-3 h-3 text-amber-500" />
|
||||
Opacidade
|
||||
</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>
|
||||
<input
|
||||
type="range"
|
||||
@@ -231,9 +228,9 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
|
||||
max={100}
|
||||
value={opacity}
|
||||
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(50)} className="hover:text-amber-600">50%</button>
|
||||
<button onClick={() => onOpacityChange(75)} className="hover:text-amber-600">75%</button>
|
||||
@@ -241,12 +238,12 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botão Tela Cheia */}
|
||||
{/* Botão Tela Cheia Compacto */}
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,12 @@ export const routeConfig: RouteDefinition[] = [
|
||||
guard: 'resource',
|
||||
resourceKey: 'ofs-lista',
|
||||
},
|
||||
{
|
||||
path: '/ordens-fabricacao',
|
||||
component: () => import('@/pages/OrdensFabricacao'),
|
||||
guard: 'resource',
|
||||
resourceKey: 'ofs-lista',
|
||||
},
|
||||
{
|
||||
path: '/ofs/cronograma',
|
||||
component: () => import('@/pages/CronogramaOF'),
|
||||
@@ -177,6 +183,14 @@ export const routeConfig: RouteDefinition[] = [
|
||||
resourceKey: 'visualizador-3d',
|
||||
},
|
||||
|
||||
// ── Analisador 3D (QA/QC & Auditoria Geométrica) ──────────
|
||||
{
|
||||
path: '/analisador-3d',
|
||||
component: () => import('@/pages/Analisador3D'),
|
||||
guard: 'resource',
|
||||
resourceKey: 'analisador-3d',
|
||||
},
|
||||
|
||||
// ── Painel Industrial ────────────────────────────────────
|
||||
{
|
||||
path: '/painel-industrial',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export const GRUPOS_MODULOS: GrupoRecurso[] = [
|
||||
descricao: 'Projetos, modelos 3D, fichas técnicas e cronogramas',
|
||||
recursos: [
|
||||
{ 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-cronograma', nome: 'Cronogramas de Fabricação', rota: '/ofs/cronograma' },
|
||||
{ key: 'cadastro-pecas', nome: 'Cadastro de Peças & Conjuntos', rota: '/cadastro/pecas' },
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface Peca {
|
||||
user_id: string;
|
||||
prioridade: string;
|
||||
quantidadeDisponivel?: number;
|
||||
revisao_atual?: string;
|
||||
tem_revisao?: boolean;
|
||||
qtd_revisoes?: number;
|
||||
}
|
||||
|
||||
interface PecaPrioridade {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useUserPermissions } from '@/hooks/useUserPermissions';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import type { ResourceKey, PermissionLevel } from '@/hooks/useUserPermissions/types';
|
||||
import { GRUPOS_AVANCADOS_CONFIG } from '@/types/grupoAvancadoTypes';
|
||||
|
||||
export function usePermissions() {
|
||||
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 {
|
||||
isAdmin,
|
||||
checkResourceAccess,
|
||||
getPermissionForResource,
|
||||
canPerformAction,
|
||||
userPermissions
|
||||
canRegra,
|
||||
userPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -503,6 +503,8 @@ export async function loadAndAuditWRL(
|
||||
}
|
||||
mesh.material = material;
|
||||
|
||||
const pieceSection = parsed.cleanMark || parsed.pieceMark || 'Perfil';
|
||||
|
||||
const pieceInfo: PieceInfo = {
|
||||
expressID,
|
||||
guid,
|
||||
@@ -512,18 +514,21 @@ export async function loadAndAuditWRL(
|
||||
cleanMark: parsed.cleanMark,
|
||||
assemblyMark: parsed.assemblyMark,
|
||||
cleanAssemblyMark: parsed.cleanAssemblyMark,
|
||||
section: pieceSection,
|
||||
phase: parsed.phase,
|
||||
mesh,
|
||||
};
|
||||
|
||||
// Injeta metadados essenciais no userData do Mesh
|
||||
// Injeta metadados essenciais no userData do Mesh (com total compatibilidade IFC / Three.js)
|
||||
mesh.userData = {
|
||||
ifcId: expressID, // Identificador primordial compatível com Raycaster, Hover e Seleção do visualizador
|
||||
expressID,
|
||||
guid,
|
||||
pieceMark: parsed.pieceMark,
|
||||
cleanMark: parsed.cleanMark,
|
||||
assemblyMark: parsed.assemblyMark,
|
||||
cleanAssemblyMark: parsed.cleanAssemblyMark,
|
||||
section: pieceSection,
|
||||
phase: parsed.phase,
|
||||
type: parsed.detectedType,
|
||||
name: rawNodeName,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { FichaTecnicaPreview } from '@/components/forms/FichaTecnicaPreview';
|
||||
import { useThemeConfig } from '@/hooks/useThemeConfig';
|
||||
import { useFichaTecnica, FichaTecnicaData } from '@/hooks/useFichaTecnica';
|
||||
import { useUserManagement } from '@/hooks/useUserManagement';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
interface InfoCalculoEstrutural {
|
||||
e: boolean;
|
||||
@@ -179,6 +180,7 @@ const CadastroOF = () => {
|
||||
const { themeConfig } = useThemeConfig();
|
||||
const { buscarFichaTecnica, salvarFichaTecnica, loading: fichaTecnicaLoading } = useFichaTecnica();
|
||||
const { users } = useUserManagement();
|
||||
const { canRegra } = usePermissions();
|
||||
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -288,6 +290,16 @@ const CadastroOF = () => {
|
||||
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);
|
||||
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,9 @@ import { usePecasP4Listener } from '@/hooks/usePecasP4Listener';
|
||||
import { PecaForm } from '@/components/pecas/PecaForm';
|
||||
import { PecasTable } from '@/components/pecas/PecasTable';
|
||||
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 { usePermissionControl } from '@/hooks/usePermissionControl';
|
||||
|
||||
@@ -39,6 +42,8 @@ export default function CadastroPecas() {
|
||||
const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
|
||||
const [pecaParaRevisao, setPecaParaRevisao] = useState<Peca | null>(null);
|
||||
const [showPainelRevisoes, setShowPainelRevisoes] = useState(false);
|
||||
|
||||
const handleSave = async (formData: any) => {
|
||||
if (!canCreate()) return false;
|
||||
@@ -242,6 +247,8 @@ export default function CadastroPecas() {
|
||||
onDeletePecasBatch={canDelete() ? handleDeleteBatch : undefined}
|
||||
onEditPeca={canEdit() ? handleEdit : undefined}
|
||||
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
|
||||
onOpenRevisaoPeca={(p) => setPecaParaRevisao(p)}
|
||||
onOpenPainelRevisoes={() => setShowPainelRevisoes(true)}
|
||||
hasRecentImport={hasRecentImport}
|
||||
canEdit={canEdit()}
|
||||
canDelete={canDelete()}
|
||||
@@ -260,6 +267,29 @@ export default function CadastroPecas() {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,10 @@ import { ArrowLeft, Plus, Package } from 'lucide-react';
|
||||
import { PecaForm } from '@/components/pecas/PecaForm';
|
||||
import { PecasTable } from '@/components/pecas/PecasTable';
|
||||
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 { useRevisoes } from '@/hooks/useRevisoes';
|
||||
import { toast } from 'sonner';
|
||||
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
||||
import { StandardPageLayout } from '@/components/layout/StandardPageLayout';
|
||||
@@ -34,11 +37,15 @@ export default function CadastroPecasFiltrado() {
|
||||
batchUpdatePecas,
|
||||
loadPecas
|
||||
} = usePecas();
|
||||
|
||||
const { revisoes: revisoesOF, carregarRevisoesOF } = useRevisoes(ofSelecionada);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
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
|
||||
const pecasFiltradas = pecas.filter(peca => peca.of_number === ofSelecionada);
|
||||
@@ -170,8 +177,10 @@ export default function CadastroPecasFiltrado() {
|
||||
useEffect(() => {
|
||||
if (!ofSelecionada) {
|
||||
navigate('/seletor-of');
|
||||
} else {
|
||||
carregarRevisoesOF(ofSelecionada);
|
||||
}
|
||||
}, [ofSelecionada, navigate]);
|
||||
}, [ofSelecionada, navigate, carregarRevisoesOF]);
|
||||
|
||||
if (loading) {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -197,7 +214,7 @@ export default function CadastroPecasFiltrado() {
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/ordens-fabricacao')}
|
||||
onClick={handleVoltar}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -269,6 +286,9 @@ export default function CadastroPecasFiltrado() {
|
||||
onEditPeca={canEdit() ? handleEdit : undefined}
|
||||
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
|
||||
onBatchUpdatePecas={canEdit() ? handleBatchUpdatePecas : undefined}
|
||||
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
|
||||
onOpenPainelRevisoes={() => setShowPainelRevisoes(true)}
|
||||
totalRevisoesOF={revisoesOF.length}
|
||||
hasRecentImport={hasRecentImport}
|
||||
canEdit={canEdit()}
|
||||
canDelete={canDelete()}
|
||||
@@ -288,6 +308,30 @@ export default function CadastroPecasFiltrado() {
|
||||
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>
|
||||
</StandardPageLayout>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ import { useState, useEffect } from 'react';
|
||||
import { useDiarioProducao, type RecursoProducao, type ApontamentoDiarioRecurso, type LoteSoldaDiario } from '@/hooks/useDiarioProducao';
|
||||
import { useUserProfile } from '@/hooks/useUserProfile';
|
||||
import { useFichaTecnica } from '@/hooks/useFichaTecnica';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface DiarioData {
|
||||
id?: string;
|
||||
@@ -28,6 +30,7 @@ interface DiarioData {
|
||||
}
|
||||
|
||||
const DiarioProducao = () => {
|
||||
const { canRegra } = usePermissions();
|
||||
const [activeTab, setActiveTab] = useState('diario');
|
||||
const [selectedOF, setSelectedOF] = useState('');
|
||||
const [showOFModal, setShowOFModal] = useState(false);
|
||||
@@ -131,6 +134,12 @@ const DiarioProducao = () => {
|
||||
|
||||
const salvarDiarioCompleto = async () => {
|
||||
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
|
||||
const diarioSalvo = await salvarDiario.mutateAsync({
|
||||
data: currentDiario.data,
|
||||
@@ -608,17 +617,31 @@ const DiarioProducao = () => {
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Printer className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => diario.id && deletarDiario.mutate(diario.id)}
|
||||
disabled={deletarDiario.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
{canRegra('pcp_exportar_relatorios') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Exportar/Imprimir Diário"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+14
-4
@@ -29,8 +29,11 @@ import { useOFsAtivas } from '@/hooks/useOFsAtivas';
|
||||
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
|
||||
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
|
||||
import { RelatoriosExpedicao } from '@/components/expedicao/RelatoriosExpedicao';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const Expedicao = () => {
|
||||
const { canRegra } = usePermissions();
|
||||
const { isMobile } = useMobileResponsive();
|
||||
const { data: romaneios, isLoading, refetch } = useRomaneios();
|
||||
const { data: ofsAtivas, isLoading: isLoadingOFs } = useOFsAtivas();
|
||||
@@ -104,6 +107,11 @@ const Expedicao = () => {
|
||||
};
|
||||
|
||||
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) {
|
||||
try {
|
||||
await removerRomaneioMutation.mutateAsync(romaneioToDelete);
|
||||
@@ -321,10 +329,12 @@ const Expedicao = () => {
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
<Button size={isMobile ? "sm" : "default"} onClick={() => setShowRomaneioForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Novo Romaneio
|
||||
</Button>
|
||||
{canRegra('exp_criar_romaneio') && (
|
||||
<Button size={isMobile ? "sm" : "default"} onClick={() => setShowRomaneioForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Novo Romaneio
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Users, UserPlus, Shield, Settings, Mail, Activity, LayoutGrid } from 'l
|
||||
import { UsersTable } from '@/components/users/UsersTable';
|
||||
import { PendingUsersTable } from '@/components/users/PendingUsersTable';
|
||||
import { FunctionsManager } from '@/components/users/FunctionsManager';
|
||||
import { PrivilegesManager } from '@/components/users/PrivilegesManager';
|
||||
import { PasswordResetRequests } from '@/components/users/PasswordResetRequests';
|
||||
import { SessionLogsSimple } from '@/components/users/SessionLogsSimple';
|
||||
import { UserModal } from '@/components/users/UserModal';
|
||||
@@ -40,9 +39,6 @@ const UserManagement = () => {
|
||||
createFunction,
|
||||
updateFunction,
|
||||
deleteFunction,
|
||||
createPrivilege: createPrivilegeBase,
|
||||
updatePrivilege: updatePrivilegeBase,
|
||||
deletePrivilege,
|
||||
updateUser,
|
||||
toggleUserStatus,
|
||||
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) => {
|
||||
if (!canEdit()) return;
|
||||
setSelectedUser(user);
|
||||
@@ -158,7 +145,7 @@ const UserManagement = () => {
|
||||
</div>
|
||||
|
||||
<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
|
||||
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"
|
||||
@@ -210,17 +197,6 @@ const UserManagement = () => {
|
||||
</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() && (
|
||||
<TabsTrigger
|
||||
value="session-logs"
|
||||
@@ -311,27 +287,6 @@ const UserManagement = () => {
|
||||
</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() && (
|
||||
<TabsContent value="session-logs" className="space-y-4 mt-4">
|
||||
<SessionLogsSimple users={users} />
|
||||
|
||||
@@ -695,7 +695,7 @@ export default function Visualizador3D() {
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<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">
|
||||
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,46 +1,61 @@
|
||||
#!/bin/bash
|
||||
# ---------------------------------------------------------
|
||||
# TRACKSTEEL APP: DEPLOY E SINCRONIZAÇÃO
|
||||
# TRACKSTEEL APP: DEPLOY & SINCRONIZAÇÃO (MARCOS-VPS -> PROD & HOSTINGER)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
set -e
|
||||
|
||||
CYAN='\033[0;36m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
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 .
|
||||
|
||||
if git diff-index --quiet HEAD --; then
|
||||
echo -e "${GREEN}✨ Nenhuma nova alteração para comitar.${NC}"
|
||||
else
|
||||
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}"
|
||||
git commit -m "$MSG"
|
||||
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
|
||||
|
||||
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"
|
||||
TRACKSTEEL_UUID="i8o44gggg00o88ccc8oo48kk"
|
||||
curl -s -X GET "https://painel.reifonas.cloud/api/v1/deploy?uuid=${TRACKSTEEL_UUID}&force=false" \
|
||||
-H "Authorization: Bearer $COOLIFY_TOKEN"
|
||||
echo -e "\n${GREEN}🚀 Deploy local (Hostinger) engatilhado com sucesso!${NC}"
|
||||
-H "Authorization: Bearer $COOLIFY_TOKEN" >/dev/null 2>&1 || true
|
||||
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}"
|
||||
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"
|
||||
echo -e "\n${GREEN}🏁 Ciclo de deploy e replicação concluído com sucesso!${NC}\n"
|
||||
|
||||
Reference in New Issue
Block a user