Compare commits

...

41 Commits

Author SHA1 Message Date
admtracksteel 8ebc8dec5c fix(auth): harden profile sync and fallback on non-uuid id; restore kong gateway 2026-10-30 13:51:00 -03:00
admtracksteel 6a8290f394 feat(expedicao): adicionar badge de maior comprimento nas pecas do romaneio 2026-10-28 10:51:22 -03:00
admtracksteel 7f98ae9803 fix(expedicao): suporte a modo claro e escuro no modal PecasRomaneioExpandido 2026-10-28 10:49:45 -03:00
admtracksteel 2bf0ccd8ce fix(expedicao): isolar peca principal no seletor de romaneio sem somar componentes 2026-10-28 10:33:03 -03:00
admtracksteel ea447df298 fix(producao): liberacao automatica de pecas na solda por kits completos no corte 2026-10-26 11:59:05 -03:00
admtracksteel 1e191fe562 feat(expedicao): fluxo inteligente de confirmacao de entrega de romaneio com data real e apontamento em cascata 2026-10-21 16:05:21 -03:00
admtracksteel ada8e4c460 fix(apontamento): isolar estado de quantidade no card para evitar perda de foco ao digitar 2026-10-21 15:30:01 -03:00
admtracksteel d28e131d7a feat: suporte a pecas e OFs sem componentes cadastrados no corte e solda 2026-10-20 16:42:21 -03:00
admtracksteel 8a707894e0 feat: adicionar somatorias de peso nos 4 cards de KPIs do mapa termico de kits 2026-10-20 15:53:21 -03:00
admtracksteel 9ade5d0d5a fix: seletor de OFs no Mapa Termico de Kits 2026-10-20 15:37:40 -03:00
admtracksteel fcb3fc08fd feat: mapa termico de kits de corte e liberacao balanceada para solda 2026-10-20 15:28:21 -03:00
admtracksteel f3eff4b805 feat: adicionar exibição da fase da OF no histórico de apontamentos 2026-10-20 13:34:22 -03:00
admtracksteel 264c41cc80 fix: isolar componentes e aplicar trava de teto na disponibilidade do Modo Smart 2026-10-20 11:05:25 -03:00
admtracksteel 9745964837 feat: blindagem de peso em dashboards e kit completo de corte para solda 2026-10-16 19:13:24 -03:00
admtracksteel 400be62a0e fix(diff-modal): adicionar scroll vertical e thead sticky para todas as pecas 2026-10-16 16:40:38 -03:00
admtracksteel 1203650c2d fix: resolucao definitiva da extracao e persistencia de comprimento de pecas e componentes 2026-10-16 16:24:38 -03:00
admtracksteel ebcfe3c9d2 fix: persistencia e extracao de comprimento de pecas e subcomponentes na importacao 2026-10-16 15:43:15 -03:00
admtracksteel ec980cba05 feat: sistema de deteccao de conflitos, conciliacao e ordem de intervencao fabril na importacao de pecas 2026-10-16 15:27:30 -03:00
admtracksteel ac8bacc9e1 feat(smart-3d): replica card de engenharia e adiciona ferramenta girar 90 com trava 2026-10-15 16:29:17 -03:00
admtracksteel 16414ca4ba feat(3d-viewer): enriquecer identificacao de pecas WRL com marca principal e perfil oficial 2026-10-15 16:05:27 -03:00
admtracksteel a5e82bf440 feat(3d-viewer): replica selecao e hover para modelos WRL com destaque magenta e apontamentos 2026-10-15 10:21:49 -03:00
admtracksteel fdec79f81b feat(3d-viewer): replica selecao e hover para modelos WRL com destaque magenta e apontamentos 2026-10-15 10:02:49 -03:00
admtracksteel 9cbc9b383e fix: Alinhamento vertical dos controles 3D - largura uniforme e alinhamento de bordas 2026-10-08 14:19:48 -03:00
admtracksteel a8275bda8c feat: Layout dos botoes 3D - botao de ortogonalidade ao lado do cubo e expansao da luminosidade 2026-10-08 14:03:21 -03:00
admtracksteel 885a076122 feat: Reposicionamento vertical dos controles de luminosidade e enquadrar abaixo do card de giro no Visualizador 3D 2026-10-08 12:14:48 -03:00
admtracksteel 29a4800ff5 feat: Aramado limpo sem linhas internas (EdgesGeometry) no Analisador 3D e compactação de layout da sidebar 3D 2026-10-08 11:52:48 -03:00
admtracksteel fbdf7e379e feat: Ferramenta de rotação 90° e trava de memória no Analisador 3D 2026-10-08 10:45:17 -03:00
admtracksteel 7f4afc5e61 fix: Calibragem do fundo no modo claro do visualizador 3D e prevenção de circular JSON 2026-10-08 10:27:48 -03:00
admtracksteel f42976a09d feat: Novo módulo Analisador 3D BIM & QA/QC (Auditoria de Furação e Comparador de Reviseeeees) 2026-10-08 10:16:48 -03:00
Marcos Reifonas d177184a62 fix(rotas): cadastrar alias /ordens-fabricacao e corrigir botao voltar no cadastro de pecas 2026-10-08 07:32:15 -03:00
Marcos Reifonas 161f73edd6 fix(pecas): destacar marca da peça em vermelho/laranja para revisões sem sobrepor tag comp 2026-10-08 07:25:12 -03:00
Marcos Reifonas 28c23a7324 fix: suporte completo ao tema claro e escuro nos modais de revisoes e botoes da tabela 2026-10-07 23:15:28 -03:00
Marcos Reifonas c239dffdee feat: adicionar gestao de revisoes simples e criticas de pecas com integracao a apontamentos 2026-10-07 22:48:50 -03:00
admtracksteel 8755852fbd feat: add direct db injection button to Bocad Converter 2026-09-29 10:10:47 -03:00
admtracksteel e537d731f3 fix: logic to extract main parts in BocadConverter 2026-09-29 09:55:41 -03:00
admtracksteel 36d5902836 🚀 Auto-deploy: TrackSteel APP atualizado em 25/09/2026 16:49:48 2026-09-25 16:49:48 -03:00
admtracksteel 8e64ad6042 docs: atualizacao da base de conhecimento com as implementacoes de 25/09/2026 2026-09-25 15:11:29 -03:00
admtracksteel 81ba4e8e3e feat: consolidacao matriz de acessos, modais avancados e ocultacao dinamica de menus 2026-09-25 14:58:08 -03:00
admtracksteel 6f595d6839 feat: Alto contraste no modo claro e criacao de templates customizados no Modo Smart 2026-09-25 12:06:27 -03:00
admtracksteel 246e8d08f2 feat: Gestao Avancada de Privilegios e Notificacoes do Modo Smart 2026-09-25 11:18:47 -03:00
admtracksteel 8847c93ac2 feat(infra): reverse replication and update script for marcos-vps 2026-09-25 09:34:46 -03:00
87 changed files with 14623 additions and 1149 deletions
+2 -1
View File
@@ -697,7 +697,8 @@ R: Sim, Supabase fornece PostgREST automático. Pode integrar com qualquer siste
## 30. Versões e atualizações deste KB ## 30. Versões e atualizações deste KB
- **v1.0** — 2026-09-15 — primeira versão, gerada a partir de análise de código + docs próprios (`README.md`, `PLANO_MODO_SMART.md`, `migracao_vps_plano.md`) - **v1.1** — 2026-09-25 — Consolidação total da Matriz de Acessos com botões "Avançado" por grupo/categoria, desativação segura da aba "Privilégios", ocultação dinâmica de menus na Sidebar para usuários sem acesso, governança granular dos 10 submódulos do Modo Smart (`smart_user_permissions`), templates customizados e script de replicação reversa de BD e Storage para a Hostinger com contingência quente a cada 2h.
- **v1.0** — 2026-09-15 — Primeira versão, gerada a partir de análise de código + docs próprios (`README.md`, `PLANO_MODO_SMART.md`, `migracao_vps_plano.md`).
--- ---
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
# ==============================================================================
# SCRIPT DE REPLICAÇÃO REVERSA: MARCOS-VPS (PROD) -> HOSTINGER (BACKUP/RÉPLICA)
# ==============================================================================
# Sincroniza schemas de negócio (TS_ERP, gpi, tracksteel, rdo) e storage
# da VPS Local (marcos-vps) para o Supabase na Hostinger (100.106.199.108 / 187.77.227.172).
# ==============================================================================
set -euo pipefail
CYAN='\033[0;36m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
LOG_DIR="/root/Apps/TracksteelApp/scripts/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/sync_db_to_hostinger_$(date +'%Y%m%d').log"
log() {
local msg="[$(date +'%Y-%m-%d %H:%M:%S')] $1"
echo -e "$1"
echo -e "$msg" >> "$LOG_FILE"
}
log "\n${CYAN}======================================================${NC}"
log "${CYAN}🔄 Iniciando Replicação Reversa: marcos-vps -> Hostinger${NC}"
log "${CYAN}======================================================${NC}"
# Containers
SRC_DB_CONTAINER="supabase-db-rjbxu55kggzc59d4cmo133cf"
DEST_DB_CONTAINER="supabase-db-h0oggskgs0ws0sco8kc4s8ws"
# Determinar IP de Conexão com a Hostinger (preferência Tailscale, fallback IP público)
DEST_IP="100.106.199.108"
log "${YELLOW}📡 Testando conexão SSH com Hostinger via Tailscale ($DEST_IP)...${NC}"
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "true" 2>/dev/null; then
log "${YELLOW}⚠️ Tailscale inacessível. Tentando IP público Hostinger (187.77.227.172)...${NC}"
DEST_IP="187.77.227.172"
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "true" 2>/dev/null; then
log "${RED}❌ ERRO: Não foi possível conectar via SSH à Hostinger ($DEST_IP). Replicação abortada.${NC}"
exit 1
fi
fi
log "${GREEN}✅ Conectividade com Hostinger confirmada ($DEST_IP).${NC}"
# Lista de Schemas a sincronizar
SCHEMAS=('TS_ERP' 'gpi' 'tracksteel' 'rdo')
for schema in "${SCHEMAS[@]}"; do
EXISTS=$(docker exec "$SRC_DB_CONTAINER" psql -U postgres -d postgres -tAc \
"SELECT 1 FROM pg_namespace WHERE nspname = '$schema';" 2>/dev/null || echo "0")
if [ "$EXISTS" = "1" ]; then
log "${YELLOW}📦 Sincronizando schema: $schema...${NC}"
TMP_DUMP="/tmp/sync_rev_${schema}_$$.dump"
# 1. Gerar Dump local comprimido (marcos-vps)
docker exec -i "$SRC_DB_CONTAINER" pg_dump -U postgres -d postgres -n "\"$schema\"" -Fc > "$TMP_DUMP"
# 2. Transferir para Hostinger via SCP
scp -q -o BatchMode=yes -o ConnectTimeout=5 "$TMP_DUMP" "root@$DEST_IP:$TMP_DUMP"
# 3. Restaurar na Hostinger e restabelecer permissões
ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" \
"docker cp '$TMP_DUMP' '$DEST_DB_CONTAINER:$TMP_DUMP' && \
(docker exec '$DEST_DB_CONTAINER' pg_restore -U postgres -d postgres --clean --if-exists --no-owner --no-privileges '$TMP_DUMP' || true) && \
docker exec '$DEST_DB_CONTAINER' psql -U postgres -d postgres -c \"GRANT USAGE ON SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role, authenticator; GRANT ALL ON ALL TABLES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; GRANT ALL ON ALL SEQUENCES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; GRANT ALL ON ALL ROUTINES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON TABLES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON SEQUENCES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON ROUTINES TO anon, authenticated, service_role; NOTIFY pgrst, 'reload schema';\" && \
rm -f '$TMP_DUMP' && \
docker exec '$DEST_DB_CONTAINER' rm -f '$TMP_DUMP'"
# 4. Limpar dump local
rm -f "$TMP_DUMP"
log "${GREEN} ↳ Schema $schema replicado na Hostinger com sucesso.${NC}"
fi
done
# Sincronização de Metadados de Storage (buckets e objects)
log "${YELLOW}📁 Sincronizando metadados de Storage (buckets & objects)...${NC}"
STORAGE_SQL="/tmp/sync_storage_rev_$$.sql"
docker exec -i "$SRC_DB_CONTAINER" pg_dump -U postgres -d postgres -t storage.buckets -t storage.objects --data-only --on-conflict-do-nothing > "$STORAGE_SQL" 2>/dev/null || true
if [ -s "$STORAGE_SQL" ]; then
scp -q -o BatchMode=yes -o ConnectTimeout=5 "$STORAGE_SQL" "root@$DEST_IP:$STORAGE_SQL"
ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" \
"docker exec -i '$DEST_DB_CONTAINER' psql -U postgres -d postgres < '$STORAGE_SQL' >/dev/null 2>&1 && rm -f '$STORAGE_SQL'"
rm -f "$STORAGE_SQL"
log "${GREEN} ↳ Metadados de storage sincronizados na Hostinger.${NC}"
else
rm -f "$STORAGE_SQL"
fi
# Sincronização de arquivos físicos de Storage (MinIO)
SRC_STORAGE="/data/coolify/services/rjbxu55kggzc59d4cmo133cf/volumes/storage/"
DEST_STORAGE="/data/coolify/services/h0oggskgs0ws0sco8kc4s8ws/volumes/storage/"
if [ -d "$SRC_STORAGE" ]; then
log "${YELLOW}☁️ Sincronizando arquivos físicos do Storage para a Hostinger...${NC}"
rsync -aqz --delete -e "ssh -o BatchMode=yes -o ConnectTimeout=5" "$SRC_STORAGE" "root@$DEST_IP:$DEST_STORAGE" || true
log "${GREEN} ↳ Arquivos do Storage espelhados com sucesso na Hostinger.${NC}"
fi
# Validação final de integridade de tabelas em TS_ERP
ORIG_COUNT=$(docker exec "$SRC_DB_CONTAINER" psql -U postgres -d postgres -tAc "SELECT count(*) FROM pg_tables WHERE schemaname = 'TS_ERP';" 2>/dev/null || echo "0")
DEST_COUNT=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "docker exec '$DEST_DB_CONTAINER' psql -U postgres -d postgres -tAc \"SELECT count(*) FROM pg_tables WHERE schemaname = 'TS_ERP';\"" 2>/dev/null || echo "0")
log "${CYAN}📊 Validação de Integridade:${NC}"
log " - Tabelas TS_ERP (Origem marcos-vps): $ORIG_COUNT"
log " - Tabelas TS_ERP (Destino Hostinger): $DEST_COUNT"
if [ "$ORIG_COUNT" = "$DEST_COUNT" ] && [ "$ORIG_COUNT" != "0" ]; then
log "${GREEN}✨ Replicação reversa concluída com 100% de paridade!${NC}\n"
else
log "${YELLOW}⚠️ Aviso: Contagem de tabelas ($ORIG_COUNT vs $DEST_COUNT). Verifique os logs.${NC}\n"
fi
+65 -14
View File
@@ -59,6 +59,8 @@ export function AppSidebar() {
'estoque': '#8b5cf6', // roxo 'estoque': '#8b5cf6', // roxo
'ofs': '#eab308', // amarelo 'ofs': '#eab308', // amarelo
'producao': '#ef4444', // vermelho 'producao': '#ef4444', // vermelho
'visualizador-3d': '#8b5cf6', // roxo
'analisador-3d': '#6366f1', // indigo
'painel-industrial': '#06b6d4', // ciano 'painel-industrial': '#06b6d4', // ciano
'expedicao': '#ec4899', // rosa 'expedicao': '#ec4899', // rosa
'obra': '#a3a3a3', // marrom 'obra': '#a3a3a3', // marrom
@@ -100,46 +102,67 @@ export function AppSidebar() {
// Admin can always access everything // Admin can always access everything
if (isAdmin) return true; if (isAdmin) return true;
// Mapeamento de chave de menu/submenu da sidebar para resource_key do BD // Mapeamento completo de chave de menu/submenu da sidebar para resource_key do BD
const itemToResourceMap: Record<string, string> = { const itemToResourceMap: Record<string, string> = {
// PCP & Produção
'pcp': 'producao-pcp', 'pcp': 'producao-pcp',
'dashboard-producao': 'producao-dashboard', 'dashboard-producao': 'producao-dashboard',
'apontamento-producao': 'producao-apontamento', 'apontamento-producao': 'producao-apontamento',
'diario-producao': 'diario-producao', 'diario-producao': 'diario-producao',
'prioridades-fabricacao': 'prioridades-fabricacao', 'prioridades-fabricacao': 'prioridades-fabricacao',
'visao-geral': 'producao-visao', 'visao-geral': 'producao-visao',
'painel-industrial': 'producao-dashboard',
// Engenharia, 3D & OFs
'visualizador-3d': 'visualizador-3d', 'visualizador-3d': 'visualizador-3d',
'analisador-3d': 'analisador-3d',
'ordens-fabricacao': 'ofs-lista', 'ordens-fabricacao': 'ofs-lista',
'ofs-concluidas': 'ofs-lista',
'ficha-tecnica-of': 'cadastro-of', 'ficha-tecnica-of': 'cadastro-of',
'cronograma': 'ofs-cronograma', 'cronograma': 'ofs-cronograma',
'cadastro-pecas': 'cadastro-pecas', 'cadastro-pecas': 'cadastro-pecas',
'equipamentos': 'equipamentos', 'equipamentos': 'equipamentos',
// Expedição & Estoque
'expedicao': 'expedicao', 'expedicao': 'expedicao',
'estoque-main': 'estoque', 'estoque-main': 'estoque',
'estoque': 'estoque', 'estoque': 'estoque',
'solicitacao-compras': 'estoque-solicitacao-compras', 'solicitacao-compras': 'estoque-solicitacao-compras',
// Obra & Campo
'dashboard-obras': 'obra-dashboard', 'dashboard-obras': 'obra-dashboard',
'configuracoes-obra': 'obra-configuracoes', 'configuracoes-obra': 'obra-configuracoes',
'obra': 'obra-dashboard', 'obra': 'obra-dashboard',
// Tarefas & Ferramentas
'tarefas': 'tarefas',
'lista-tarefas': 'tarefas',
'historico-tarefas': 'tarefas',
'ferramentas': 'ferramentas',
'conversores': 'ferramentas-inconsistencias',
'inconsistencias': 'ferramentas-inconsistencias',
// Biblioteca & Sistema
'biblioteca': 'sistema',
'catalogos': 'sistema',
'normas': 'sistema',
'referencias': 'sistema',
'sistema': 'sistema',
'sugestoes': 'sugestoes',
'atribuicoes': 'user-management',
'mapa-interativo': 'dashboard',
'dashboard': 'dashboard',
// Administração & Usuários
'gerenciar-usuarios': 'user-management', 'gerenciar-usuarios': 'user-management',
'user-management': 'user-management', 'user-management': 'user-management',
'configuracoes-gerais': 'configuracoes-gerais', 'configuracoes-gerais': 'configuracoes-gerais',
'inconsistencias': 'ferramentas-inconsistencias', 'personalizacao-tema': 'admin',
'ferramentas': 'ferramentas',
'sistema': 'sistema',
'sugestoes': 'sugestoes',
'admin': 'admin', 'admin': 'admin',
'dashboard': 'dashboard',
}; };
const resKey = itemToResourceMap[itemKey] || itemKey; const resKey = itemToResourceMap[itemKey] || itemKey;
// Se há restrição direta configurada no BD para este recurso // 1. Se há restrição direta configurada no BD para este recurso
if (resourcePermissions && resourcePermissions[resKey]) { if (resourcePermissions && resourcePermissions[resKey]) {
return resourcePermissions[resKey] !== 'no_access'; return resourcePermissions[resKey] !== 'no_access';
} }
// Se o usuário tem perfil de "apenas Smart" (tem modo-smart mas não tem esse recurso) // 2. Se o usuário tem perfil de "apenas Smart" (tem modo-smart mas não tem outros recursos)
const temAcessoSmart = const temAcessoSmart =
resourcePermissions && resourcePermissions &&
resourcePermissions['modo-smart'] && resourcePermissions['modo-smart'] &&
@@ -156,8 +179,23 @@ export function AppSidebar() {
if (soTemSmart && !resKey.startsWith('smart') && resKey !== 'modo-smart') { if (soTemSmart && !resKey.startsWith('smart') && resKey !== 'modo-smart') {
return false; return false;
} }
// 3. Se há permissões individuais salvas na Matriz para este usuário:
// Se a matriz foi configurada para o usuário, qualquer tela que não foi concedida (exceto dashboard básico) fica oculta
if (permsCadastradas.length > 0) {
if (resourcePermissions[resKey] === 'no_access') {
return false;
}
if (resKey !== 'dashboard' && !resKey.startsWith('smart')) {
// Se não consta na lista de permissões ativas concedidas
const perm = resourcePermissions[resKey];
if (!perm || perm === 'no_access') {
return false;
}
}
}
// Se o item requer permissão especial, verificar permissões específicas // 4. Se o item requer permissão especial, verificar permissões específicas
if (requiresSpecialPermission) { if (requiresSpecialPermission) {
if (itemKey === 'ferramentas') { if (itemKey === 'ferramentas') {
return canAccessTools(); return canAccessTools();
@@ -235,12 +273,25 @@ export function AppSidebar() {
{menuGroups.map(group => { {menuGroups.map(group => {
// Filter admin groups for non-admin users // Filter admin groups for non-admin users
if (!isAdmin && group.name === 'Administração') { if (!isAdmin && group.name === 'Administração') {
logger.debug('Ocultando grupo admin para usuário não-admin'); const hasAnyAdminItem = group.items.some(item => canAccessItem(item.key, item.requiresSpecialPermission));
return null; if (!hasAnyAdminItem) {
return null;
}
} }
// Skip groups with no items // Filtrar itens do grupo permitidos para o usuário logado
if (!group.items || group.items.length === 0) { const itemsPermitidos = (group.items || []).filter(item => {
if (item.subItems && item.subItems.length > 0) {
return isAdmin || item.subItems.some(sub => {
if (sub.url === "/admin/theme-customization" && !isAdmin) return false;
return canAccessItem(sub.key, sub.requiresSpecialPermission);
});
}
return isAdmin || canAccessItem(item.key, item.requiresSpecialPermission);
});
// Se não houver itens visíveis no grupo, não renderiza o grupo (nem o título)
if (itemsPermitidos.length === 0) {
return null; return null;
} }
@@ -254,7 +305,7 @@ export function AppSidebar() {
</SidebarGroupLabel> </SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{group.items.map((item) => ( {itemsPermitidos.map((item) => (
<AppSidebarMenuItem <AppSidebarMenuItem
key={item.key || item.title} key={item.key || item.title}
item={item} item={item}
+140 -65
View File
@@ -49,10 +49,10 @@ export const ApontamentoForm = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [cacheValido, setCacheValido] = useState(false); const [cacheValido, setCacheValido] = useState(false);
const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos } = useApontamentosProducao(); const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos, sincronizarKitsOF } = useApontamentosProducao();
const { pecas, loading: loadingPecas } = usePecas(); const { pecas, loading: loadingPecas } = usePecas();
const { ofs } = useOFs(); const { ofs } = useOFs();
const { componentesAgrupados, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas); const { componentesAgrupados, componentesRaw, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
// Buscar fases únicas da OF selecionada // Buscar fases únicas da OF selecionada
const fasesDisponiveis = useMemo(() => { const fasesDisponiveis = useMemo(() => {
@@ -118,6 +118,8 @@ export const ApontamentoForm = () => {
const processosOrdenados = [...processos].sort((a, b) => a.ordem - b.ordem); const processosOrdenados = [...processos].sort((a, b) => a.ordem - b.ordem);
const indexAtual = processosOrdenados.findIndex(p => p.id === processo_id); const indexAtual = processosOrdenados.findIndex(p => p.id === processo_id);
const procAtual = processosOrdenados[indexAtual]; const procAtual = processosOrdenados[indexAtual];
const procNomeLower = (procAtual?.nome || '').toLowerCase();
const isCorte = procNomeLower.includes('corte') || procNomeLower.includes('prepar') || indexAtual === 0;
// 1. Peças da OF e Fase selecionadas // 1. Peças da OF e Fase selecionadas
const pecasDaFase = pecas.filter( const pecasDaFase = pecas.filter(
@@ -134,25 +136,60 @@ export const ApontamentoForm = () => {
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0; let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
// Regra: peças sem componentes não passam por solda // Identificar se a peça de fato possui subcomponentes cadastrados nesta OF
if (!peca.tem_componentes && procAtual && procAtual.nome.toLowerCase().includes('solda')) { const compsDaPeca = componentesAgrupados?.filter(c => c.peca_ids?.includes(peca.id)) || [];
const temComponentesCadastrados = peca.tem_componentes !== false && compsDaPeca.length > 0;
const ehSemMontagem = peca.tem_componentes === false;
// Se for CORTE:
// Se tiver componentes cadastrados, são apontados os subcomponentes.
// Se NÃO tiver componentes cadastrados (opção do usuário não controlar componentes), aponta a peça mestre normalmente!
if (isCorte && temComponentesCadastrados) {
qtdDisponivelParaEntrar = 0;
} else if (ehSemMontagem && procNomeLower.includes('solda')) {
// Regra: peças sem montagem (S/M) não passam por solda
qtdDisponivelParaEntrar = 0; qtdDisponivelParaEntrar = 0;
} else if (indexAtual > 0) { } else if (indexAtual > 0) {
// Encontrar processo anterior válido // Encontrar processo anterior válido
let processoAnteriorValido = null; let processoAnteriorValido = null;
for (let i = indexAtual - 1; i >= 0; i--) { for (let i = indexAtual - 1; i >= 0; i--) {
const p = processosOrdenados[i]; const p = processosOrdenados[i];
if (!peca.tem_componentes && p.nome.toLowerCase().includes('solda')) { if (ehSemMontagem && p.nome.toLowerCase().includes('solda')) {
continue; // Pula a solda na busca do processo anterior para peças simples continue; // Pula a solda na busca do processo anterior para peças simples (S/M)
} }
processoAnteriorValido = p; processoAnteriorValido = p;
break; break;
} }
if (processoAnteriorValido) { if (processoAnteriorValido) {
qtdDisponivelParaEntrar = apontamentos let qtdApontadaAnterior = apontamentos
.filter(a => a.tipo_apontamento === 'peca' && a.peca_id === peca.id && a.processo_id === processoAnteriorValido.id) .filter(a => a.tipo_apontamento === 'peca' && a.peca_id === peca.id && a.processo_id === processoAnteriorValido.id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0); .reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
// Se a peça tem componentes e o processo anterior for Corte:
// Calcula estritamente os kits completos disponíveis baseados no corte de todos os componentes (regra do gargalo)
if (temComponentesCadastrados && processoAnteriorValido.nome.toLowerCase().includes('corte')) {
const compsDestaPeca = (componentesRaw || []).filter(c => c.peca_id === peca.id);
if (compsDestaPeca.length > 0) {
let menorKits = Number(peca.quantidade) || 0;
compsDestaPeca.forEach(comp => {
const cortado = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && a.componente_id === comp.id && a.processo_id === processoAnteriorValido.id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const kitsDeste = Math.floor(cortado / porPeca);
if (kitsDeste < menorKits) {
menorKits = kitsDeste;
}
});
const kitsCompletosCalculados = Math.max(0, Math.min(Number(peca.quantidade) || 0, menorKits));
if (kitsCompletosCalculados > qtdApontadaAnterior) {
qtdApontadaAnterior = kitsCompletosCalculados;
}
}
}
qtdDisponivelParaEntrar = qtdApontadaAnterior;
} }
} }
@@ -173,21 +210,14 @@ export const ApontamentoForm = () => {
}); });
// 2. Componentes da OF e Fase selecionadas // 2. Componentes da OF e Fase selecionadas
// RESTRIÇÃO INDUSTRIAL: Componentes só existem no processo de CORTE / PREPARAÇÃO!
// Em Solda, Pintura, Expedição, Montagem etc., componentes não são exibidos nem apontados.
const componentesDisponiveis: ItemDisponivel[] = []; const componentesDisponiveis: ItemDisponivel[] = [];
if (componentesAgrupados && componentesAgrupados.length > 0) { if (isCorte && componentesAgrupados && componentesAgrupados.length > 0) {
componentesAgrupados.forEach(comp => { componentesAgrupados.forEach(comp => {
let qtdDisponivelParaEntrarComp = Number(comp.quantidade_total) || 0; let qtdDisponivelParaEntrarComp = Number(comp.quantidade_total) || 0;
if (indexAtual > 0) {
const processoAnteriorValido = processosOrdenados[indexAtual - 1];
if (processoAnteriorValido) {
qtdDisponivelParaEntrarComp = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processoAnteriorValido.id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
}
}
const totalApontadoComp = apontamentos const totalApontadoComp = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processo_id) .filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processo_id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0); .reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
@@ -263,23 +293,12 @@ export const ApontamentoForm = () => {
try { try {
for (const item of items) { for (const item of items) {
try { try {
const apontamentoData: Parameters<typeof criarApontamento>[0] = { const result = await registrarItemComDistribuicao(
of_number: formData.of_number, item,
tipo_apontamento: item.tipo, item.quantidade_disponivel,
processo_id: formData.processo_id, `Registro em lote - ${tipoTexto}`
quantidade_produzida: item.quantidade_disponivel, );
data_apontamento: formData.data_apontamento,
observacoes: `Registro em lote - ${tipoTexto}`
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
const result = await criarApontamento(apontamentoData);
if (result.success) { if (result.success) {
sucessos++; sucessos++;
} else { } else {
@@ -341,6 +360,13 @@ export const ApontamentoForm = () => {
setFormData(prev => ({ ...prev, ...updates })); setFormData(prev => ({ ...prev, ...updates }));
updateCache({ processo_id: processoId }); updateCache({ processo_id: processoId });
setItemSelecionado(null); setItemSelecionado(null);
// Ao selecionar Solda, acionar sincronização de kits da OF em segundo plano
const proc = processos.find(p => p.id === processoId);
const procNome = (proc?.nome || '').toLowerCase();
if ((procNome.includes('solda') || procNome.includes('montag')) && formData.of_number) {
sincronizarKitsOF(formData.of_number);
}
}; };
const handleItemSelect = (item: ItemDisponivel) => { const handleItemSelect = (item: ItemDisponivel) => {
@@ -367,6 +393,81 @@ export const ApontamentoForm = () => {
})); }));
}; };
// Função auxiliar para registrar apontamento distribuindo componentes entre peças irmãs se necessário
const registrarItemComDistribuicao = async (
item: ItemDisponivel,
quantidade: number,
observacoes?: string
): Promise<{ success: boolean }> => {
if (item.tipo === 'componente') {
const grupo = componentesAgrupados.find(g => g.marca_componente === item.marca);
if (grupo && grupo.vinculos && grupo.vinculos.length > 1) {
let restante = quantidade;
let sucessos = 0;
for (const v of grupo.vinculos) {
if (restante <= 0) break;
const jaCortado = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && a.componente_id === v.componente_id && a.processo_id === formData.processo_id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
const faltaParaPeca = Math.max(0, v.quantidade_necessaria - jaCortado);
if (faltaParaPeca <= 0) continue;
const qtdParaV = Math.min(restante, faltaParaPeca);
restante -= qtdParaV;
const res = await criarApontamento({
of_number: formData.of_number,
tipo_apontamento: 'componente',
processo_id: formData.processo_id,
quantidade_produzida: qtdParaV,
data_apontamento: formData.data_apontamento,
componente_id: v.componente_id,
observacoes: observacoes || undefined
});
if (res.success) sucessos++;
}
// Se sobrou saldo além das necessidades das peças vinculadas, aplica ao primeiro vínculo
if (restante > 0 && grupo.vinculos.length > 0) {
const res = await criarApontamento({
of_number: formData.of_number,
tipo_apontamento: 'componente',
processo_id: formData.processo_id,
quantidade_produzida: restante,
data_apontamento: formData.data_apontamento,
componente_id: grupo.vinculos[0].componente_id,
observacoes: observacoes || undefined
});
if (res.success) sucessos++;
}
return { success: sucessos > 0 };
}
}
// Caso padrão (peça ou componente monovinculado)
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: observacoes || undefined
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
return await criarApontamento(apontamentoData);
};
const handleApontarItemDireto = async (item: ItemDisponivel, quantidade: number): Promise<boolean> => { const handleApontarItemDireto = async (item: ItemDisponivel, quantidade: number): Promise<boolean> => {
if (!formData.processo_id) { if (!formData.processo_id) {
toast.error('Selecione um processo antes de apontar'); toast.error('Selecione um processo antes de apontar');
@@ -380,22 +481,7 @@ export const ApontamentoForm = () => {
setSaving(true); setSaving(true);
try { try {
const apontamentoData: Parameters<typeof criarApontamento>[0] = { const result = await registrarItemComDistribuicao(item, quantidade, formData.observacoes || undefined);
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || undefined
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
const result = await criarApontamento(apontamentoData);
if (result.success) { if (result.success) {
toast.success(`${quantidade} un de ${item.marca} apontada(s) com sucesso!`); toast.success(`${quantidade} un de ${item.marca} apontada(s) com sucesso!`);
@@ -443,22 +529,11 @@ export const ApontamentoForm = () => {
setSaving(true); setSaving(true);
try { try {
const apontamentoData: Parameters<typeof criarApontamento>[0] = { const result = await registrarItemComDistribuicao(
of_number: formData.of_number, itemSelecionado,
tipo_apontamento: itemSelecionado.tipo, quantidade,
processo_id: formData.processo_id, formData.observacoes || undefined
quantidade_produzida: quantidade, );
data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || undefined
};
if (itemSelecionado.tipo === 'componente') {
apontamentoData.componente_id = itemSelecionado.id;
} else {
apontamentoData.peca_id = itemSelecionado.id;
}
const result = await criarApontamento(apontamentoData);
if (result.success) { if (result.success) {
toast.success('Apontamento registrado com sucesso!'); toast.success('Apontamento registrado com sucesso!');
@@ -12,12 +12,15 @@ import { ptBR } from 'date-fns/locale';
export const ApontamentosList = () => { export const ApontamentosList = () => {
const { apontamentos, loading } = useApontamentosProducao(); const { apontamentos, loading } = useApontamentosProducao();
const [filtroOF, setFiltroOF] = useState(''); const [filtroOF, setFiltroOF] = useState('');
const [filtroFase, setFiltroFase] = useState('');
const [filtroProcesso, setFiltroProcesso] = useState(''); const [filtroProcesso, setFiltroProcesso] = useState('');
const [filtroData, setFiltroData] = useState(''); const [filtroData, setFiltroData] = useState('');
const [filtroPecaComponente, setFiltroPecaComponente] = useState(''); const [filtroPecaComponente, setFiltroPecaComponente] = useState('');
const apontamentosFiltrados = apontamentos.filter(apt => { const apontamentosFiltrados = apontamentos.filter(apt => {
const matchOF = !filtroOF || apt.of_number.toLowerCase().includes(filtroOF.toLowerCase()); const matchOF = !filtroOF || apt.of_number.toLowerCase().includes(filtroOF.toLowerCase());
const faseStr = apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase || '';
const matchFase = !filtroFase || faseStr.toLowerCase().includes(filtroFase.toLowerCase());
const matchProcesso = !filtroProcesso || apt.processo?.nome.toLowerCase().includes(filtroProcesso.toLowerCase()); const matchProcesso = !filtroProcesso || apt.processo?.nome.toLowerCase().includes(filtroProcesso.toLowerCase());
const matchData = !filtroData || apt.data_apontamento.includes(filtroData); const matchData = !filtroData || apt.data_apontamento.includes(filtroData);
@@ -25,7 +28,7 @@ export const ApontamentosList = () => {
const matchPecaComponente = !filtroPecaComponente || const matchPecaComponente = !filtroPecaComponente ||
getMarcaExibida(apt).toLowerCase().includes(filtroPecaComponente.toLowerCase()); getMarcaExibida(apt).toLowerCase().includes(filtroPecaComponente.toLowerCase());
return matchOF && matchProcesso && matchData && matchPecaComponente; return matchOF && matchFase && matchProcesso && matchData && matchPecaComponente;
}); });
// Função para determinar a marca exibida baseada no tipo de apontamento // Função para determinar a marca exibida baseada no tipo de apontamento
@@ -90,7 +93,7 @@ export const ApontamentosList = () => {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3"> <div className="grid grid-cols-1 md:grid-cols-6 gap-3">
<div> <div>
<label className="text-sm font-medium mb-2 block">OF</label> <label className="text-sm font-medium mb-2 block">OF</label>
<Input <Input
@@ -100,6 +103,15 @@ export const ApontamentosList = () => {
className="h-8" className="h-8"
/> />
</div> </div>
<div>
<label className="text-sm font-medium mb-2 block">Fase</label>
<Input
placeholder="Filtrar por fase..."
value={filtroFase}
onChange={(e) => setFiltroFase(e.target.value)}
className="h-8"
/>
</div>
<div> <div>
<label className="text-sm font-medium mb-2 block">Processo</label> <label className="text-sm font-medium mb-2 block">Processo</label>
<Input <Input
@@ -163,6 +175,7 @@ export const ApontamentosList = () => {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>OF</TableHead> <TableHead>OF</TableHead>
<TableHead>Fase</TableHead>
<TableHead>Peça/Componente</TableHead> <TableHead>Peça/Componente</TableHead>
<TableHead>Tipo</TableHead> <TableHead>Tipo</TableHead>
<TableHead>Processo</TableHead> <TableHead>Processo</TableHead>
@@ -184,6 +197,17 @@ export const ApontamentosList = () => {
<TableCell className="font-medium py-1"> <TableCell className="font-medium py-1">
{apontamento.of_number} {apontamento.of_number}
</TableCell> </TableCell>
<TableCell className="py-1">
{apontamento.peca?.etapa_fase ? (
<Badge variant="outline" className="text-xs">
{apontamento.peca.etapa_fase.toLowerCase().startsWith('fase') || apontamento.peca.etapa_fase.toLowerCase().startsWith('etapa')
? apontamento.peca.etapa_fase
: `Fase ${apontamento.peca.etapa_fase}`}
</Badge>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="py-1"> <TableCell className="py-1">
<div className="font-medium"> <div className="font-medium">
{getMarcaExibida(apontamento)} {getMarcaExibida(apontamento)}
@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Search, Package, Shield, AlertTriangle, Settings, Check, Loader2 } from 'lucide-react'; import { Search, Package, Shield, AlertTriangle, Settings, Check, Loader2, Layers } from 'lucide-react';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
import { Peca } from '@/hooks/usePecas'; import { Peca } from '@/hooks/usePecas';
import { useComponentesPeca } from '@/hooks/useComponentesPeca'; import { useComponentesPeca } from '@/hooks/useComponentesPeca';
@@ -67,6 +67,257 @@ interface PecaWithComponents extends Peca {
processo_atual_permitido?: number; processo_atual_permitido?: number;
} }
const getPrioridadeColor = (prioridade?: string) => {
switch (prioridade?.toLowerCase()) {
case 'alta': return 'text-red-600 dark:text-red-400';
case 'média': return 'text-amber-600 dark:text-yellow-400';
case 'baixa': return 'text-emerald-600 dark:text-green-400';
default: return 'text-muted-foreground';
}
};
const PecaItem = React.memo(({ peca, onSelect }: { peca: PecaWithComponents; onSelect: (peca: PecaWithComponents) => void }) => {
return (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(peca)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{peca.marca}</div>
<div className="text-xs text-muted-foreground">
Prioridade: <span className={getPrioridadeColor(peca.prioridade)}>{peca.prioridade || 'N/A'}</span>
</div>
{peca.descricao && (
<div className="text-xs text-muted-foreground truncate" title={peca.descricao}>
{peca.descricao}
</div>
)}
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
Qtd Disponível: {peca.quantidadeDisponivel || 0}
</div>
</div>
</div>
);
});
PecaItem.displayName = 'PecaItem';
const ComponenteItem = React.memo(({ componente, onSelect }: { componente: ComponenteItemData; onSelect: (componente: ComponenteItemData) => void }) => (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(componente)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{componente.marca_componente}</div>
<div className="text-xs text-muted-foreground">
Perfil: {componente.perfil || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
Peso Unitário: {componente.peso_unitario || 0}kg | Qtd por Peça: {componente.quantidade_por_peca || 1}
</div>
{componente.descricao && (
<div className="text-xs text-muted-foreground truncate" title={componente.descricao}>
{componente.descricao}
</div>
)}
</div>
</div>
));
ComponenteItem.displayName = 'ComponenteItem';
interface ItemDisponivelCardProps {
item: ItemDisponivel;
isSelected: boolean;
isAdmin?: boolean;
onSelect?: (item: ItemDisponivel) => void;
onAdminAction?: (item: ItemDisponivel) => void;
onApontarItemDireto?: (item: ItemDisponivel, quantidade: number) => Promise<boolean>;
}
const ItemDisponivelCard = React.memo(({
item,
isSelected,
isAdmin,
onSelect,
onAdminAction,
onApontarItemDireto,
}: ItemDisponivelCardProps) => {
const [qtdValue, setQtdValue] = useState<string>('');
const [isTodas, setIsTodas] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
// Mantém sincronizado se a quantidade disponível mudar ou modo 'todas' estiver ativo
useEffect(() => {
if (isTodas) {
setQtdValue(item.quantidade_disponivel.toString());
} else if (qtdValue) {
const parsed = parseInt(qtdValue, 10);
if (!isNaN(parsed) && parsed > item.quantidade_disponivel) {
setQtdValue(item.quantidade_disponivel.toString());
}
}
}, [item.quantidade_disponivel, isTodas]);
const handleToggleTodas = () => {
setIsTodas((prev) => {
const novo = !prev;
setQtdValue(novo ? item.quantidade_disponivel.toString() : '');
return novo;
});
};
const handleQtdChange = (val: string) => {
setQtdValue(val);
if (isTodas) {
setIsTodas(false);
}
};
const handleExecutarBaixa = async () => {
if (!onApontarItemDireto || isLoading) return;
const quantidade = isTodas ? item.quantidade_disponivel : parseInt(qtdValue, 10);
if (isNaN(quantidade) || quantidade <= 0) {
toast.error(`Informe a quantidade para ${item.marca} ou ative 'Todas'`);
return;
}
if (quantidade > item.quantidade_disponivel) {
toast.error(`Quantidade não pode ser maior que ${item.quantidade_disponivel} disponível`);
return;
}
setIsLoading(true);
try {
const sucesso = await onApontarItemDireto(item, quantidade);
if (sucesso) {
setQtdValue('');
setIsTodas(false);
}
} catch (error) {
console.error('Erro ao executar baixa direta:', error);
toast.error('Erro ao registrar apontamento');
} finally {
setIsLoading(false);
}
};
return (
<div className="relative">
<div
className={`p-3 border rounded-md transition-colors ${
isSelected
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border bg-card hover:bg-accent/40'
}`}
onClick={() => onSelect?.(item)}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-foreground flex items-center gap-2">
<span>{item.marca}</span>
{item.perfil && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono font-normal">
{item.perfil}
</span>
)}
</div>
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{item.quantidade_disponivel} disponível
</div>
</div>
{item.descricao && item.descricao !== item.perfil && (
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
{item.descricao}
</div>
)}
{/* Linha com processo e controles rápidos de apontamento */}
<div className="flex items-center justify-between gap-2 pt-1 flex-wrap">
<div className="text-xs text-muted-foreground font-medium">
Processo: {item.nome_processo || item.processo_atual_permitido}
</div>
{onApontarItemDireto && (
<div
className="flex items-center gap-1.5 ml-auto"
onClick={(e) => e.stopPropagation()}
>
<Input
type="number"
min="1"
max={item.quantidade_disponivel}
placeholder="Quant."
value={qtdValue}
onChange={(e) => handleQtdChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleExecutarBaixa();
}
}}
disabled={isTodas || isLoading}
className="h-7 w-20 text-xs px-2 text-center font-medium bg-background border-input shadow-none"
/>
<Button
type="button"
size="sm"
variant={isTodas ? "default" : "outline"}
onClick={handleToggleTodas}
disabled={isLoading}
className={`h-7 px-2.5 text-xs font-semibold transition-colors ${
isTodas
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600 shadow-sm"
: "hover:bg-accent text-foreground"
}`}
title="Habilitar/desabilitar apontar todas as unidades disponíveis"
>
Todas
</Button>
<Button
type="button"
size="sm"
disabled={isLoading || (!isTodas && (!qtdValue || parseInt(qtdValue, 10) <= 0))}
onClick={handleExecutarBaixa}
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs flex items-center gap-1 shadow-sm transition-all"
title="Baixar quantidade no apontamento"
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Check className="h-3.5 w-3.5" />
<span>Baixar</span>
</>
)}
</Button>
</div>
)}
</div>
</div>
</div>
{isAdmin && onAdminAction && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
onAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<Settings className="h-3 w-3" />
</Button>
)}
</div>
);
});
ItemDisponivelCard.displayName = 'ItemDisponivelCard';
export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
pecasDisponiveis = [], pecasDisponiveis = [],
componentesDisponiveis = [], componentesDisponiveis = [],
@@ -86,11 +337,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
const [loadingPecas, setLoadingPecas] = useState(false); const [loadingPecas, setLoadingPecas] = useState(false);
const [loadingComponentes, setLoadingComponentes] = useState(false); const [loadingComponentes, setLoadingComponentes] = useState(false);
const [componentes, setComponentes] = useState<ComponenteItemData[]>([]); const [componentes, setComponentes] = useState<ComponenteItemData[]>([]);
// Estados locais para apontamento direto por card (quantidade, modo todas e loading)
const [qtdCards, setQtdCards] = useState<Record<string, string>>({});
const [todasCards, setTodasCards] = useState<Record<string, boolean>>({});
const [loadingApontandoId, setLoadingApontandoId] = useState<string | null>(null);
const [showComponentes, setShowComponentes] = useState(false); const [showComponentes, setShowComponentes] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -184,59 +430,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
return comps.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' })); return comps.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }));
}, [componentesDisponiveis, filtroNumeroPeca]); }, [componentesDisponiveis, filtroNumeroPeca]);
const handleQtdCardChange = useCallback((itemId: string, value: string) => {
setQtdCards(prev => ({ ...prev, [itemId]: value }));
setTodasCards(prev => ({ ...prev, [itemId]: false }));
}, []);
const handleToggleTodas = useCallback((itemId: string, qtdDisponivel: number) => {
setTodasCards(prev => {
const novoEstado = !prev[itemId];
if (novoEstado) {
setQtdCards(q => ({ ...q, [itemId]: qtdDisponivel.toString() }));
} else {
setQtdCards(q => ({ ...q, [itemId]: '' }));
}
return { ...prev, [itemId]: novoEstado };
});
}, []);
const handleExecutarBaixa = useCallback(async (item: ItemDisponivel) => {
if (!onApontarItemDireto) return;
const isTodas = todasCards[item.id];
const qtdDigitada = qtdCards[item.id];
const quantidade = isTodas ? item.quantidade_disponivel : parseInt(qtdDigitada);
if (isNaN(quantidade) || quantidade <= 0) {
toast.error(`Informe a quantidade para ${item.marca} ou ative 'Todas'`);
return;
}
if (quantidade > item.quantidade_disponivel) {
toast.error(`Quantidade não pode ser maior que ${item.quantidade_disponivel} disponível`);
return;
}
setLoadingApontandoId(item.id);
try {
const sucesso = await onApontarItemDireto(item, quantidade);
if (sucesso) {
setQtdCards(prev => {
const copia = { ...prev };
delete copia[item.id];
return copia;
});
setTodasCards(prev => {
const copia = { ...prev };
delete copia[item.id];
return copia;
});
}
} finally {
setLoadingApontandoId(null);
}
}, [onApontarItemDireto, todasCards, qtdCards]);
const fetchPecas = useCallback(async (term: string) => { const fetchPecas = useCallback(async (term: string) => {
if (!term.trim()) { if (!term.trim()) {
setPecasFiltradas([]); setPecasFiltradas([]);
@@ -318,174 +511,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
} }
}, [onBatchSelect, pecasOrdenadas, componentesOrdenados]); }, [onBatchSelect, pecasOrdenadas, componentesOrdenados]);
const getPrioridadeColor = (prioridade: string) => {
switch (prioridade?.toLowerCase()) {
case 'alta': return 'text-red-600 dark:text-red-400';
case 'média': return 'text-amber-600 dark:text-yellow-400';
case 'baixa': return 'text-emerald-600 dark:text-green-400';
default: return 'text-muted-foreground';
}
};
const PecaItem = ({ peca, onSelect }: { peca: PecaWithComponents; onSelect: (peca: PecaWithComponents) => void }) => {
return (
<div className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(peca)}>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{peca.marca}</div>
<div className="text-xs text-muted-foreground">
Prioridade: <span className={getPrioridadeColor(peca.prioridade)}>{peca.prioridade || 'N/A'}</span>
</div>
{peca.descricao && (
<div className="text-xs text-muted-foreground truncate" title={peca.descricao}>
{peca.descricao}
</div>
)}
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
Qtd Disponível: {peca.quantidadeDisponivel || 0}
</div>
</div>
</div>
);
};
const ComponenteItem = ({ componente }: { componente: ComponenteItemData }) => (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => handleComponenteSelect(componente)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{componente.marca_componente}</div>
<div className="text-xs text-muted-foreground">
Perfil: {componente.perfil || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
Peso Unitário: {componente.peso_unitario || 0}kg | Qtd por Peça: {componente.quantidade_por_peca || 1}
</div>
{componente.descricao && (
<div className="text-xs text-muted-foreground truncate" title={componente.descricao}>
{componente.descricao}
</div>
)}
</div>
</div>
);
const ItemDisponivelComponent = ({ item, isSelected }: { item: ItemDisponivel; isSelected: boolean }) => {
const isTodas = Boolean(todasCards[item.id]);
const qtdValue = qtdCards[item.id] ?? (isTodas ? item.quantidade_disponivel.toString() : '');
const isLoading = loadingApontandoId === item.id;
return (
<div className="relative">
<div
className={`p-3 border rounded-md transition-colors ${
isSelected
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border bg-card hover:bg-accent/40'
}`}
onClick={() => handleItemSelect(item)}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-foreground flex items-center gap-2">
<span>{item.marca}</span>
{item.perfil && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono font-normal">
{item.perfil}
</span>
)}
</div>
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{item.quantidade_disponivel} disponível
</div>
</div>
{item.descricao && item.descricao !== item.perfil && (
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
{item.descricao}
</div>
)}
{/* Linha com processo e controles rápidos de apontamento desenhados pelo usuário */}
<div className="flex items-center justify-between gap-2 pt-1 flex-wrap">
<div className="text-xs text-muted-foreground font-medium">
Processo: {item.nome_processo || item.processo_atual_permitido}
</div>
{onApontarItemDireto && (
<div
className="flex items-center gap-1.5 ml-auto"
onClick={(e) => e.stopPropagation()}
>
<Input
type="number"
min="1"
max={item.quantidade_disponivel}
placeholder="Quant."
value={qtdValue}
onChange={(e) => handleQtdCardChange(item.id, e.target.value)}
disabled={isTodas || isLoading}
className="h-7 w-20 text-xs px-2 text-center font-medium bg-background border-input shadow-none"
/>
<Button
type="button"
size="sm"
variant={isTodas ? "default" : "outline"}
onClick={() => handleToggleTodas(item.id, item.quantidade_disponivel)}
disabled={isLoading}
className={`h-7 px-2.5 text-xs font-semibold transition-colors ${
isTodas
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600 shadow-sm"
: "hover:bg-accent text-foreground"
}`}
title="Habilitar/desabilitar apontar todas as unidades disponíveis"
>
Todas
</Button>
<Button
type="button"
size="sm"
disabled={isLoading || (!isTodas && (!qtdValue || parseInt(qtdValue) <= 0))}
onClick={() => handleExecutarBaixa(item)}
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs flex items-center gap-1 shadow-sm transition-all"
title="Baixar quantidade no apontamento"
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Check className="h-3.5 w-3.5" />
<span>Baixar</span>
</>
)}
</Button>
</div>
)}
</div>
</div>
</div>
{isAdmin && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
handleAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<Settings className="h-3 w-3" />
</Button>
)}
</div>
);
};
if (onSelectPeca) { if (onSelectPeca) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -527,7 +552,7 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[200px] p-2"> <ScrollArea className="rounded-md border border-border h-[200px] p-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{componentes.map((componente) => ( {componentes.map((componente) => (
<ComponenteItem key={componente.id} componente={componente} /> <ComponenteItem key={componente.id} componente={componente} onSelect={handleComponenteSelect} />
))} ))}
{componentes.length === 0 && ( {componentes.length === 0 && (
<div className="text-center text-muted-foreground col-span-full"> <div className="text-center text-muted-foreground col-span-full">
@@ -592,27 +617,15 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[300px] p-2"> <ScrollArea className="rounded-md border border-border h-[300px] p-2">
<div className="space-y-2"> <div className="space-y-2">
{pecasOrdenadas.map((item) => ( {pecasOrdenadas.map((item) => (
<div key={item.id} className="relative"> <ItemDisponivelCard
<ItemDisponivelComponent key={item.id}
item={item} item={item}
isSelected={itemSelecionado?.id === item.id} isSelected={itemSelecionado?.id === item.id}
/> isAdmin={isAdmin}
onSelect={handleItemSelect}
{isAdmin && ( onAdminAction={handleAdminAction}
<Button onApontarItemDireto={onApontarItemDireto}
size="sm" />
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
handleAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<AlertTriangle className="h-3 w-3" />
</Button>
)}
</div>
))} ))}
{pecasOrdenadas.length === 0 && ( {pecasOrdenadas.length === 0 && (
<div className="text-center text-muted-foreground py-8"> <div className="text-center text-muted-foreground py-8">
@@ -628,7 +641,10 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
{componentesDisponiveis.length > 0 && ( {componentesDisponiveis.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-foreground">Componentes Disponíveis</h3> <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Layers className="h-5 w-5 text-blue-500" />
Componentes para Corte (Controle de Kit)
</h3>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -642,10 +658,14 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[300px] p-2"> <ScrollArea className="rounded-md border border-border h-[300px] p-2">
<div className="space-y-2"> <div className="space-y-2">
{componentesOrdenados.map((item) => ( {componentesOrdenados.map((item) => (
<ItemDisponivelComponent <ItemDisponivelCard
key={item.id} key={item.id}
item={item} item={item}
isSelected={itemSelecionado?.id === item.id} isSelected={itemSelecionado?.id === item.id}
isAdmin={isAdmin}
onSelect={handleItemSelect}
onAdminAction={handleAdminAction}
onApontarItemDireto={onApontarItemDireto}
/> />
))} ))}
</div> </div>
@@ -95,7 +95,9 @@ export const ApontamentosFilters: React.FC<ApontamentosFiltersProps> = ({
<SelectContent> <SelectContent>
<SelectItem value="all">Todas as fases</SelectItem> <SelectItem value="all">Todas as fases</SelectItem>
{uniqueFases.map((fase) => ( {uniqueFases.map((fase) => (
<SelectItem key={fase} value={fase}>{fase}</SelectItem> <SelectItem key={fase} value={fase}>
{fase.toLowerCase().startsWith('fase') || fase.toLowerCase().startsWith('etapa') ? fase : `Fase ${fase}`}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -35,6 +35,15 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
} }
}; };
const getFaseItem = (apontamento: ApontamentoProducao) => {
const rawFase = apontamento.peca?.etapa_fase || (apontamento.componente as any)?.peca?.etapa_fase;
if (!rawFase || !String(rawFase).trim()) return null;
const str = String(rawFase).trim();
return str.toLowerCase().startsWith('fase') || str.toLowerCase().startsWith('etapa')
? str
: `Fase ${str}`;
};
return ( return (
<div className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors"> <div className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -52,6 +61,21 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
<Badge variant="outline"> <Badge variant="outline">
{apontamento.of_number || 'N/A'} {apontamento.of_number || 'N/A'}
</Badge> </Badge>
{getFaseItem(apontamento) ? (
<Badge
variant="secondary"
className="bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950/60 dark:text-sky-300 dark:border-sky-800 text-xs font-semibold"
>
{getFaseItem(apontamento)}
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground border-dashed text-xs"
>
Fase -
</Badge>
)}
{apontamento.is_forcado && ( {apontamento.is_forcado && (
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full border ${ <span className={`text-[10px] font-black px-2 py-0.5 rounded-full border ${
apontamento.status_confirmacao === 'pendente_confirmacao' apontamento.status_confirmacao === 'pendente_confirmacao'
@@ -100,7 +124,7 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
Tem certeza de que deseja reverter este apontamento? Tem certeza de que deseja reverter este apontamento?
Esta ação não pode ser desfeita e irá remover permanentemente o registro de: Esta ação não pode ser desfeita e irá remover permanentemente o registro de:
<br /> <br />
<strong>{getMarcaItem(apontamento)}</strong> - {apontamento.quantidade_produzida} unidades <strong>{getMarcaItem(apontamento)}</strong> (OF: {apontamento.of_number || 'N/A'}{getFaseItem(apontamento) ? ` • ${getFaseItem(apontamento)}` : ''}) - {apontamento.quantidade_produzida} unidades
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -89,12 +89,12 @@ export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
: apontamentos; : apontamentos;
const fases = apontamentosFiltrados const fases = apontamentosFiltrados
.map(apt => apt.peca?.etapa_fase) .map(apt => apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase)
.filter((fase): fase is string => { .filter((fase): fase is string => {
return typeof fase === 'string' && fase.trim() !== ''; return typeof fase === 'string' && fase.trim() !== '';
}) })
.filter((fase, index, array) => array.indexOf(fase) === index) .filter((fase, index, array) => array.indexOf(fase) === index)
.sort(); .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
console.log(`📋 Fases únicas encontradas para OF "${filterOF}": ${fases.length}`, fases); console.log(`📋 Fases únicas encontradas para OF "${filterOF}": ${fases.length}`, fases);
return fases; return fases;
@@ -166,7 +166,7 @@ export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
// Aplicar filtro por Fase APENAS se selecionado // Aplicar filtro por Fase APENAS se selecionado
if (filterFase && filterFase !== '' && filterFase !== 'all') { if (filterFase && filterFase !== '' && filterFase !== 'all') {
const beforeFase = resultado.length; const beforeFase = resultado.length;
resultado = resultado.filter(apt => apt.peca?.etapa_fase === filterFase); resultado = resultado.filter(apt => (apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase) === filterFase);
console.log(`📋 Filtro Fase "${filterFase}": ${beforeFase} → ${resultado.length} registros`); console.log(`📋 Filtro Fase "${filterFase}": ${beforeFase} → ${resultado.length} registros`);
} }
@@ -0,0 +1,53 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { MapaTermicoKitsView } from './MapaTermicoKitsView';
import { PecaKitInfo } from '@/hooks/useKitsProducao';
import { Flame } from 'lucide-react';
interface MapaTermicoKitsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ofNumber?: string;
fase?: string;
onSelectPecaParaApontar?: (peca: PecaKitInfo) => void;
}
export const MapaTermicoKitsModal: React.FC<MapaTermicoKitsModalProps> = ({
open,
onOpenChange,
ofNumber,
fase,
onSelectPecaParaApontar,
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader className="pb-2 border-b border-slate-200 dark:border-slate-800">
<DialogTitle className="flex items-center gap-2 text-lg sm:text-xl font-black text-slate-900 dark:text-slate-100 uppercase tracking-tight">
<Flame className="w-5 h-5 text-orange-500 fill-orange-500 animate-pulse" />
<span>Mapa Térmico de Kits de Montagem</span>
</DialogTitle>
</DialogHeader>
<div className="py-2">
<MapaTermicoKitsView
initialOF={ofNumber}
initialFase={fase || 'all'}
readOnlyOF={Boolean(ofNumber)}
onSelectPecaParaApontar={(peca) => {
if (onSelectPecaParaApontar) {
onSelectPecaParaApontar(peca);
onOpenChange(false);
}
}}
/>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,681 @@
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Flame,
CheckCircle2,
AlertTriangle,
Clock,
Layers,
Search,
RefreshCw,
ChevronDown,
ChevronUp,
PackageCheck,
ShieldCheck,
Wrench,
Scissors,
Info
} from 'lucide-react';
import { useKitsProducao, PecaKitInfo } from '@/hooks/useKitsProducao';
import { useOFsAtivas, OFAtiva } from '@/hooks/useOFsAtivas';
interface MapaTermicoKitsViewProps {
initialOF?: string;
initialFase?: string;
readOnlyOF?: boolean;
onSelectPecaParaApontar?: (peca: PecaKitInfo) => void;
}
export const MapaTermicoKitsView: React.FC<MapaTermicoKitsViewProps> = ({
initialOF = '',
initialFase = 'all',
readOnlyOF = false,
onSelectPecaParaApontar,
}) => {
const { ofsAtivas, isLoading: loadingOFs } = useOFsAtivas();
const [selectedOF, setSelectedOF] = useState<string>(initialOF || '');
const [selectedFase, setSelectedFase] = useState<string>(initialFase);
const [busca, setBusca] = useState<string>('');
const [filtroStatus, setFiltroStatus] = useState<'todos' | 'liberados' | 'parciais' | 'travados'>('todos');
const [pecasExpandidas, setPecasExpandidas] = useState<Set<string>>(new Set());
// Atualizar OF padrão se carregar depois
React.useEffect(() => {
if (initialOF) {
setSelectedOF(initialOF);
} else if (!selectedOF && ofsAtivas.length > 0) {
setSelectedOF(ofsAtivas[0].of_number);
}
}, [initialOF, ofsAtivas, selectedOF]);
const { listaKits, resumoKits, loading, refetch } = useKitsProducao(selectedOF);
// Extrair fases únicas da lista de kits carregada de toda a OF
const fasesDisponiveis = useMemo(() => {
const s = new Set<string>();
listaKits.forEach((k) => {
if (k.etapa_fase && k.etapa_fase.trim()) s.add(k.etapa_fase.trim());
});
return Array.from(s).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
}, [listaKits]);
// Alternar expansão de componentes de uma peça
const toggleExpand = (pecaId: string) => {
setPecasExpandidas((prev) => {
const next = new Set(prev);
if (next.has(pecaId)) {
next.delete(pecaId);
} else {
next.add(pecaId);
}
return next;
});
};
const expandirTodas = () => {
setPecasExpandidas(new Set(listaKits.map((k) => k.peca_id)));
};
const recolherTodas = () => {
setPecasExpandidas(new Set());
};
// Filtragem combinada (Fase + Status + Busca textual)
const pecasFiltradas = useMemo(() => {
return listaKits.filter((item) => {
// Filtro de fase
if (selectedFase && selectedFase !== 'all' && selectedFase !== 'todas') {
if (item.etapa_fase !== selectedFase) return false;
}
// Filtro de status
if (filtroStatus === 'liberados') {
if (item.kits_completos_cortados <= 0) return false;
} else if (filtroStatus === 'parciais') {
if (
item.kits_completos_cortados >= item.quantidade_total ||
item.status_termico === 'vermelho'
)
return false;
} else if (filtroStatus === 'travados') {
if (item.status_termico !== 'vermelho') return false;
}
// Filtro de busca textual
if (busca.trim()) {
const t = busca.toLowerCase().trim();
const bateMarca = item.marca.toLowerCase().includes(t);
const bateDesc = (item.descricao || '').toLowerCase().includes(t);
const batePerfil = (item.perfil_principal || '').toLowerCase().includes(t);
const bateComp = item.componentes.some((c) =>
c.marca_componente.toLowerCase().includes(t) || (c.perfil || '').toLowerCase().includes(t)
);
if (!bateMarca && !bateDesc && !batePerfil && !bateComp) return false;
}
return true;
});
}, [listaKits, selectedFase, filtroStatus, busca]);
// Formatação de pesos em kg
const formatarKg = (valorKg: number) => {
if (!valorKg || valorKg <= 0) return '0 kg';
return `${valorKg.toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kg`;
};
// KPIs calculados com base nas peças filtradas
const resumoExibido = useMemo(() => {
let total100 = 0;
let totalParciais = 0;
let totalTravados = 0;
let pesoTot = 0;
let pesoCort = 0;
let peso100 = 0;
let pesoUnLiberadas = 0;
let pesoParciais = 0;
let pesoNaoIniciados = 0;
let unTot = 0;
let unLib = 0;
pecasFiltradas.forEach((item) => {
unTot += item.quantidade_total;
unLib += item.kits_completos_cortados;
pesoTot += item.peso_total_peca;
pesoCort += item.peso_cortado_componentes;
pesoUnLiberadas += item.peso_kits_liberados;
if (item.status_termico === 'verde') {
total100++;
peso100 += item.peso_total_peca;
} else if (item.status_termico === 'verde_parcial' || item.status_termico === 'amarelo') {
totalParciais++;
pesoParciais += item.peso_total_peca;
} else {
totalTravados++;
pesoNaoIniciados += item.peso_total_peca;
}
});
return {
totalPecas: pecasFiltradas.length,
totalKits100Porcento: total100,
totalKitsParciais: totalParciais,
totalKitsNaoIniciados: totalTravados,
pesoTotalOFKg: pesoTot,
pesoCortadoTotalKg: pesoCort,
peso100PorcentoKg: peso100,
pesoUnidadesLiberadasKg: pesoUnLiberadas,
pesoParciaisKg: pesoParciais,
pesoNaoIniciadosKg: pesoNaoIniciados,
percentualGeralOF: pesoTot > 0 ? Math.min(100, Math.round((pesoCort / pesoTot) * 100)) : 0,
totalUnidadesTotal: unTot,
totalUnidadesLiberadasSolda: unLib,
};
}, [pecasFiltradas]);
return (
<div className="space-y-4 w-full">
{/* ──────────────── Barra de Controles e Seletores ──────────────── */}
<Card className="border border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-slate-900/90 shadow-sm">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-orange-100 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400">
<Flame className="w-5 h-5 animate-pulse" />
</div>
<div>
<h3 className="font-black text-slate-900 dark:text-slate-100 tracking-tight text-base sm:text-lg flex items-center gap-2">
<span>Mapa Térmico de Kits de Produção</span>
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:border-orange-800 dark:text-orange-400 text-xs font-bold">
Corte ➔ Solda
</Badge>
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
Prontidão física de montagem: kits cortados disponíveis para liberar a Solda
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={loading}
className="text-xs gap-1.5 h-8 font-semibold"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
<span>Atualizar</span>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-1">
{/* Seletor de OF */}
<div>
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
Ordem de Fabricação (OF)
</label>
{readOnlyOF ? (
<div className="h-9 px-3 rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-100 dark:bg-slate-800 flex items-center font-bold text-xs">
OF {selectedOF}
</div>
) : (
<Select
value={selectedOF}
onValueChange={(val) => {
setSelectedOF(val);
setSelectedFase('all');
}}
disabled={loadingOFs}
>
<SelectTrigger className="h-9 text-xs font-semibold">
<SelectValue placeholder={loadingOFs ? "Carregando OFs..." : "Selecione uma OF..."}>
{selectedOF ? `OF ${selectedOF}` : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{ofsAtivas.map((o) => (
<SelectItem key={o.of_number} value={o.of_number}>
OF {o.of_number} {o.cliente ? `• ${o.cliente}` : o.descricao_resumida ? `• ${o.descricao_resumida}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Seletor de Fase */}
<div>
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
Fase da OF
</label>
<Select value={selectedFase} onValueChange={setSelectedFase}>
<SelectTrigger className="h-9 text-xs font-semibold">
<SelectValue placeholder="Todas as fases" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as fases</SelectItem>
{fasesDisponiveis.map((f) => (
<SelectItem key={f} value={f}>
{f.toLowerCase().startsWith('fase') ? f : `Fase ${f}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Busca Rápida */}
<div>
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
Buscar Marca / Componente
</label>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-3 top-3 text-slate-400" />
<Input
placeholder="Ex: V1, p2, W200..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="h-9 pl-9 text-xs"
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* ──────────────── Cards de KPIs do Mapa Térmico ──────────────── */}
{resumoExibido && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5 sm:gap-3">
{/* KPI 1: 100% Liberadas */}
<div className="p-3.5 rounded-2xl bg-emerald-50/80 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800 shadow-sm flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-emerald-700 dark:text-emerald-400">
100% Liberadas
</span>
<span className="text-[11px] font-black text-emerald-800 dark:text-emerald-300 bg-emerald-200/80 dark:bg-emerald-900/70 px-1.5 py-0.5 rounded-md border border-emerald-300/80 dark:border-emerald-700 shadow-2xs">
{formatarKg(resumoExibido.peso100PorcentoKg)}
</span>
</div>
<div className="text-2xl font-black text-emerald-900 dark:text-emerald-100 mt-1">
{resumoExibido.totalKits100Porcento}
</div>
<div className="text-[10px] text-emerald-700/80 dark:text-emerald-400/80 font-medium truncate">
peças com todos os kits cortados
</div>
</div>
<div className="p-2.5 rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-900/60 dark:text-emerald-300 shrink-0 ml-2">
<CheckCircle2 className="w-6 h-6" />
</div>
</div>
{/* KPI 2: Kits Montáveis Liberados */}
<div className="p-3.5 rounded-2xl bg-blue-50/80 dark:bg-blue-950/40 border border-blue-200 dark:border-blue-800 shadow-sm flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-blue-700 dark:text-blue-400">
Unidades Liberadas
</span>
<span className="text-[11px] font-black text-blue-800 dark:text-blue-300 bg-blue-200/80 dark:bg-blue-900/70 px-1.5 py-0.5 rounded-md border border-blue-300/80 dark:border-blue-700 shadow-2xs">
{formatarKg(resumoExibido.pesoUnidadesLiberadasKg)}
</span>
</div>
<div className="text-2xl font-black text-blue-900 dark:text-blue-100 mt-1">
{resumoExibido.totalUnidadesLiberadasSolda} <span className="text-xs text-blue-600 font-bold">/ {resumoExibido.totalUnidadesTotal} un</span>
</div>
<div className="text-[10px] text-blue-700/80 dark:text-blue-400/80 font-medium truncate">
kits prontos na bancada de solda
</div>
</div>
<div className="p-2.5 rounded-xl bg-blue-100 text-blue-700 dark:bg-blue-900/60 dark:text-blue-300 shrink-0 ml-2">
<PackageCheck className="w-6 h-6" />
</div>
</div>
{/* KPI 3: Em Andamento / Quase Prontos */}
<div className="p-3.5 rounded-2xl bg-amber-50/80 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 shadow-sm flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-amber-700 dark:text-amber-400">
Corte em Andamento
</span>
<span className="text-[11px] font-black text-amber-800 dark:text-amber-300 bg-amber-200/80 dark:bg-amber-900/70 px-1.5 py-0.5 rounded-md border border-amber-300/80 dark:border-amber-700 shadow-2xs">
{formatarKg(resumoExibido.pesoParciaisKg)}
</span>
</div>
<div className="text-2xl font-black text-amber-900 dark:text-amber-100 mt-1">
{resumoExibido.totalKitsParciais}
</div>
<div className="text-[10px] text-amber-700/80 dark:text-amber-400/80 font-medium truncate">
kits parciais (prioridade de corte)
</div>
</div>
<div className="p-2.5 rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-900/60 dark:text-amber-300 shrink-0 ml-2">
<Clock className="w-6 h-6" />
</div>
</div>
{/* KPI 4: Travados / 0% */}
<div className="p-3.5 rounded-2xl bg-rose-50/80 dark:bg-rose-950/40 border border-rose-200 dark:border-rose-800 shadow-sm flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-rose-700 dark:text-rose-400">
Não Iniciados
</span>
<span className="text-[11px] font-black text-rose-800 dark:text-rose-300 bg-rose-200/80 dark:bg-rose-900/70 px-1.5 py-0.5 rounded-md border border-rose-300/80 dark:border-rose-700 shadow-2xs">
{formatarKg(resumoExibido.pesoNaoIniciadosKg)}
</span>
</div>
<div className="text-2xl font-black text-rose-900 dark:text-rose-100 mt-1">
{resumoExibido.totalKitsNaoIniciados}
</div>
<div className="text-[10px] text-rose-700/80 dark:text-rose-400/80 font-medium truncate">
sem nenhum componente cortado
</div>
</div>
<div className="p-2.5 rounded-xl bg-rose-100 text-rose-700 dark:bg-rose-900/60 dark:text-rose-300 shrink-0 ml-2">
<AlertTriangle className="w-6 h-6" />
</div>
</div>
</div>
)}
{/* ──────────────── Filtros por Status Térmico & Ações em Massa ──────────────── */}
<div className="flex items-center justify-between gap-2 flex-wrap text-xs">
<div className="flex items-center gap-1.5 flex-wrap">
<button
type="button"
onClick={() => setFiltroStatus('todos')}
className={`px-3 py-1.5 rounded-xl font-bold transition-colors ${
filtroStatus === 'todos'
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900 shadow-sm'
: 'bg-slate-100 hover:bg-slate-200 text-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300'
}`}
>
Todas ({listaKits.length})
</button>
<button
type="button"
onClick={() => setFiltroStatus('liberados')}
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
filtroStatus === 'liberados'
? 'bg-emerald-600 text-white shadow-sm'
: 'bg-emerald-50 text-emerald-800 hover:bg-emerald-100 dark:bg-emerald-950/50 dark:text-emerald-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-emerald-400"></span>
Liberadas para Solda ({listaKits.filter((k) => k.kits_completos_cortados > 0).length})
</button>
<button
type="button"
onClick={() => setFiltroStatus('parciais')}
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
filtroStatus === 'parciais'
? 'bg-amber-600 text-white shadow-sm'
: 'bg-amber-50 text-amber-800 hover:bg-amber-100 dark:bg-amber-950/50 dark:text-amber-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-amber-400"></span>
Em Andamento / Quase Prontas ({listaKits.filter((k) => k.status_termico === 'amarelo' || (k.kits_completos_cortados > 0 && k.kits_completos_cortados < k.quantidade_total)).length})
</button>
<button
type="button"
onClick={() => setFiltroStatus('travados')}
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
filtroStatus === 'travados'
? 'bg-rose-600 text-white shadow-sm'
: 'bg-rose-50 text-rose-800 hover:bg-rose-100 dark:bg-rose-950/50 dark:text-rose-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-rose-400"></span>
Travadas no Corte ({listaKits.filter((k) => k.status_termico === 'vermelho').length})
</button>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={expandirTodas} className="h-7 text-[11px] font-semibold text-slate-600 dark:text-slate-400">
Expandir Todos
</Button>
<Button variant="ghost" size="sm" onClick={recolherTodas} className="h-7 text-[11px] font-semibold text-slate-600 dark:text-slate-400">
Recolher Todos
</Button>
</div>
</div>
{/* ──────────────── Lista de Peças com Mapa Térmico ──────────────── */}
{!loading && listaKits.length > 0 && !listaKits.some((k) => k.componentes && k.componentes.length > 0) && (
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-900 dark:text-amber-200 flex items-center gap-3 shadow-xs">
<Info className="w-5 h-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="text-xs leading-relaxed">
<strong className="font-bold">Modo Peça Direta:</strong> Esta OF não possui subcomponentes cadastrados no sistema. O avanço no <strong>Corte</strong> e a liberação das peças para a <strong>Solda</strong> são gerenciados diretamente pelo apontamento das peças principais.
</div>
</div>
)}
{loading ? (
<div className="py-16 flex flex-col items-center justify-center text-slate-400 gap-3">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-orange-500 border-t-transparent" />
<span className="text-sm font-semibold">Calculando matriz de kits e completude...</span>
</div>
) : pecasFiltradas.length === 0 ? (
<Card className="border border-dashed border-slate-300 dark:border-slate-800 p-8 text-center bg-slate-50/50 dark:bg-slate-900/50">
<Layers className="w-10 h-10 text-slate-400 mx-auto mb-2 opacity-60" />
<h4 className="font-bold text-slate-700 dark:text-slate-300 text-sm">Nenhuma peça encontrada</h4>
<p className="text-xs text-slate-500 mt-1">Tente ajustar a busca ou os filtros de status</p>
</Card>
) : (
<div className="space-y-3">
{pecasFiltradas.map((peca) => {
const isExpanded = pecasExpandidas.has(peca.peca_id);
const totalKits = peca.quantidade_total;
const kitsProntos = peca.kits_completos_cortados;
const pct = peca.percentual_termico;
const eh100 = kitsProntos >= totalKits;
const temParcial = kitsProntos > 0 && kitsProntos < totalKits;
const gargalo = peca.componente_gargalo;
// Cores térmicas
let barColor = 'bg-rose-500';
let badgeBg = 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/60 dark:text-rose-300 dark:border-rose-800';
if (eh100) {
barColor = 'bg-emerald-500';
badgeBg = 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/60 dark:text-emerald-300 dark:border-emerald-800';
} else if (temParcial) {
barColor = 'bg-emerald-500';
badgeBg = 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/60 dark:text-blue-300 dark:border-blue-800';
} else if (pct >= 40) {
barColor = 'bg-amber-500';
badgeBg = 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/60 dark:text-amber-300 dark:border-amber-800';
}
return (
<div
key={peca.peca_id}
className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900/90 shadow-sm overflow-hidden transition-all hover:border-slate-300 dark:hover:border-slate-700"
>
{/* Linha Principal do Card */}
<div
onClick={() => toggleExpand(peca.peca_id)}
className="p-4 cursor-pointer flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:bg-slate-50/50 dark:hover:bg-slate-800/40 transition-colors select-none"
>
{/* Lado Esquerdo: Identificação da Peça */}
<div className="flex items-start gap-3 min-w-0">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 font-black text-sm shadow-sm ${
eh100
? 'bg-emerald-500 text-white'
: temParcial
? 'bg-blue-500 text-white'
: pct >= 40
? 'bg-amber-500 text-slate-950'
: 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400'
}`}
>
{pct}%
</div>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-lg font-black text-slate-900 dark:text-slate-100 tracking-wide">
{peca.marca}
</span>
<Badge variant="outline" className="text-[11px] font-bold">
{peca.etapa_fase.toLowerCase().startsWith('fase') ? peca.etapa_fase : `Fase ${peca.etapa_fase}`}
</Badge>
{peca.perfil_principal && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-semibold font-mono">
{peca.perfil_principal}
</span>
)}
{peca.tem_componentes === false && (
<Badge variant="secondary" className="bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300 text-[10px] font-black">
S/M (Sem Montagem)
</Badge>
)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5 flex-wrap">
<span className="line-clamp-1">{peca.descricao || 'Peça Estrutural'}</span>
{peca.peso_unitario > 0 && (
<span className="font-semibold text-slate-600 dark:text-slate-400 whitespace-nowrap">
• {peca.peso_unitario.toFixed(1)} kg/un (Total: {peca.peso_total_peca.toFixed(1)} kg)
</span>
)}
</div>
</div>
</div>
{/* Lado Direito: Status dos Kits e Métricas */}
<div className="flex items-center gap-3 shrink-0 justify-between sm:justify-end">
<div className="text-right">
<div className="flex items-center justify-end gap-1.5 flex-wrap">
<span className={`text-xs font-black px-2.5 py-0.5 rounded-full border ${badgeBg}`}>
{eh100
? `✅ 100% Liberado (${kitsProntos}/${totalKits} un)`
: temParcial
? `🟢 ${kitsProntos}/${totalKits} kits liberados na Solda`
: pct >= 40
? `🟡 0/${totalKits} kits (Corte ${pct}%)`
: `🔴 0/${totalKits} kits (Não iniciado)`}
</span>
</div>
{/* Gargalo em Destaque */}
{gargalo && kitsProntos < totalKits && (
<div className="text-[11px] font-semibold text-amber-700 dark:text-amber-400 mt-1 flex items-center justify-end gap-1">
<AlertTriangle className="w-3 h-3 text-amber-500 shrink-0" />
<span>
Gargalo: <strong>{gargalo.marca_componente}</strong> (faltam {gargalo.falta_para_prox_kit} un para próx. kit)
</span>
</div>
)}
</div>
<div className="p-1 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
{isExpanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
</div>
</div>
</div>
{/* Barra Térmica de Progresso */}
<div className="px-4 pb-3">
<div className="w-full bg-slate-100 dark:bg-slate-800 h-2.5 rounded-full overflow-hidden flex">
<div
className={`h-full transition-all duration-300 ${barColor}`}
style={{ width: `${pct}%` }}
title={`Prontidão térmica do kit: ${pct}%`}
/>
</div>
</div>
{/* ──────────────── Detalhamento dos Componentes do Kit (Expansível) ──────────────── */}
{isExpanded && (
<div className="px-4 pb-4 pt-2 border-t border-slate-100 dark:border-slate-800/80 bg-slate-50/60 dark:bg-slate-950/40 space-y-2">
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300 mb-2">
<span className="flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-blue-500" />
Componentes Requeridos para {totalKits}x {peca.marca} ({peca.componentes.length} itens no kit):
</span>
{onSelectPecaParaApontar && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onSelectPecaParaApontar(peca);
}}
className="h-7 text-xs bg-amber-500 hover:bg-amber-600 text-slate-950 font-black gap-1"
>
<Scissors className="w-3 h-3" />
Apontar no Corte
</Button>
)}
</div>
{peca.componentes.length === 0 ? (
<p className="text-xs text-slate-500 italic py-1">
Esta peça não possui subcomponentes cadastrados (é fabricada como peça inteira).
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{peca.componentes.map((comp) => {
const isGargalo = gargalo?.id === comp.id && kitsProntos < totalKits;
const comp100 = comp.concluido;
return (
<div
key={comp.id}
className={`p-2.5 rounded-xl border text-xs transition-colors flex items-center justify-between gap-2 ${
isGargalo
? 'bg-amber-50 dark:bg-amber-950/40 border-amber-400 dark:border-amber-700 shadow-sm'
: comp100
? 'bg-emerald-50/50 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-800/60'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800'
}`}
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-mono font-black text-slate-900 dark:text-slate-100">
{comp.marca_componente}
</span>
{comp.perfil && (
<span className="text-[10px] px-1.5 py-0.2 rounded bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 font-mono">
{comp.perfil}
</span>
)}
{isGargalo && (
<span className="text-[9px] font-black px-1.5 py-0.5 rounded bg-amber-500 text-slate-950 uppercase tracking-tight">
Gargalo
</span>
)}
</div>
<div className="text-[11px] text-slate-500 dark:text-slate-400">
{comp.quantidade_por_peca} un/peça • Total da OF: <strong>{comp.quantidade_total_necessaria} un</strong>
</div>
</div>
<div className="text-right shrink-0">
<div className="font-mono font-black text-xs text-slate-800 dark:text-slate-200">
{comp.quantidade_cortada} / {comp.quantidade_total_necessaria} un
</div>
<div className="text-[10px] font-bold text-slate-500 dark:text-slate-400">
Dá para {comp.kits_possiveis} {comp.kits_possiveis === 1 ? 'kit' : 'kits'} ({comp.percentual_cortado}%)
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
@@ -26,6 +26,9 @@ import * as XLSX from 'xlsx';
import { useAppLabels } from '@/hooks/useAppLabels'; import { useAppLabels } from '@/hooks/useAppLabels';
import { usePecas } from '@/hooks/usePecas'; import { usePecas } from '@/hooks/usePecas';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { analisarConflitosImportacao, consolidarPecasEComponentes, extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
export interface ComponentItem { export interface ComponentItem {
marca: string; marca: string;
@@ -145,6 +148,9 @@ const AdvanceSteelConverterContent: React.FC = () => {
const [statusText, setStatusText] = useState<string>('Aguardando arquivo PDF...'); const [statusText, setStatusText] = useState<string>('Aguardando arquivo PDF...');
const [currentFileName, setCurrentFileName] = useState<string>('Lista_Pecas'); const [currentFileName, setCurrentFileName] = useState<string>('Lista_Pecas');
const [useIframeMode, setUseIframeMode] = useState<boolean>(false); const [useIframeMode, setUseIframeMode] = useState<boolean>(false);
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const logEndRef = useRef<HTMLDivElement>(null); const logEndRef = useRef<HTMLDivElement>(null);
@@ -753,6 +759,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
// Aba 3: Formato Plano Oficial para Importação TrackSteel // Aba 3: Formato Plano Oficial para Importação TrackSteel
const excelRowsImportacao: any[] = []; const excelRowsImportacao: any[] = [];
extractedData.forEach((item) => { extractedData.forEach((item) => {
const comprPecaNum = item.comprimentoMax === '-' ? '' : Number(item.comprimentoMax) || '';
if (item.components && item.components.length > 0) { if (item.components && item.components.length > 0) {
item.components.forEach((comp) => { item.components.forEach((comp) => {
excelRowsImportacao.push({ excelRowsImportacao.push({
@@ -763,6 +770,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade), quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit), peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal), peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPecaNum,
comprimento_ref: comprPecaNum,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal, tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material, material: item.material,
perfil_principal: item.perfilPrincipal, perfil_principal: item.perfilPrincipal,
@@ -771,6 +780,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: comp.perfil || item.descricao, descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao, perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnitario || 0, peso_unitario_componente: comp.pesoUnitario || 0,
comprimento_componente: comp.comprimento || '',
quantidade_por_peca: Number(comp.quantidade) || 1 quantidade_por_peca: Number(comp.quantidade) || 1
}); });
}); });
@@ -783,6 +793,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade), quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit), peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal), peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPecaNum,
comprimento_ref: comprPecaNum,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal, tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material, material: item.material,
perfil_principal: item.perfilPrincipal, perfil_principal: item.perfilPrincipal,
@@ -791,6 +803,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: '', descricao_componente: '',
perfil_componente: '', perfil_componente: '',
peso_unitario_componente: 0, peso_unitario_componente: 0,
comprimento_componente: '',
quantidade_por_peca: 0 quantidade_por_peca: 0
}); });
} }
@@ -829,8 +842,12 @@ const AdvanceSteelConverterContent: React.FC = () => {
try { try {
const importacaoRows: any[] = []; const importacaoRows: any[] = [];
extractedData.forEach((item) => { extractedData.forEach((item) => {
const comprPeca = extrairComprimentoSeguro(item.comprimentoMax) ?? extrairComprimentoSeguro(item);
if (item.components && item.components.length > 0) { if (item.components && item.components.length > 0) {
item.components.forEach((comp) => { item.components.forEach((comp) => {
const comprComp = extrairComprimentoComponenteSeguro(comp.comprimento) ?? extrairComprimentoComponenteSeguro(comp);
importacaoRows.push({ importacaoRows.push({
of_number: item.of, of_number: item.of,
etapa_fase: item.fase, etapa_fase: item.fase,
@@ -839,6 +856,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade), quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit), peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal), peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPeca,
comprimento_ref: comprPeca,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal, tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material, material: item.material,
perfil_principal: item.perfilPrincipal, perfil_principal: item.perfilPrincipal,
@@ -847,6 +866,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: comp.perfil || item.descricao, descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao, perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnitario || 0, peso_unitario_componente: comp.pesoUnitario || 0,
comprimento_componente: comprComp,
quantidade_por_peca: Number(comp.quantidade) || 1 quantidade_por_peca: Number(comp.quantidade) || 1
}); });
}); });
@@ -859,6 +879,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade), quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit), peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal), peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPeca,
comprimento_ref: comprPeca,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal, tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material, material: item.material,
perfil_principal: item.perfilPrincipal, perfil_principal: item.perfilPrincipal,
@@ -867,11 +889,24 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: '', descricao_componente: '',
perfil_componente: '', perfil_componente: '',
peso_unitario_componente: 0, peso_unitario_componente: 0,
comprimento_componente: null,
quantidade_por_peca: 0 quantidade_por_peca: 0
}); });
} }
}); });
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(importacaoRows);
const { componentesMap } = consolidarPecasEComponentes(importacaoRows);
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsDirectImporting(false);
return;
}
await importPecas(importacaoRows); await importPecas(importacaoRows);
toast.success(`${extractedData.length} peças e seus componentes foram importados com sucesso!`); toast.success(`${extractedData.length} peças e seus componentes foram importados com sucesso!`);
addLog(`✅ Importação concluída: ${extractedData.length} peças gravadas no banco.`); addLog(`✅ Importação concluída: ${extractedData.length} peças gravadas no banco.`);
@@ -1160,6 +1195,19 @@ const AdvanceSteelConverterContent: React.FC = () => {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Modal de Detecção de Conflitos e Conciliação */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={headerOf || ''}
onConcluido={() => {
setShowDiffModal(false);
addLog(`✅ Importação conciliada concluída com sucesso.`);
}}
/>
</div> </div>
); );
}; };
+130 -7
View File
@@ -24,6 +24,10 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { usePecas } from '@/hooks/usePecas';
import { analisarConflitosImportacao, consolidarPecasEComponentes, extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
export interface BocadConvertedPiece { export interface BocadConvertedPiece {
of: string; of: string;
@@ -39,6 +43,7 @@ export interface BocadConvertedPiece {
perfilPrincipal: string; perfilPrincipal: string;
comprimentoRef: number | string; comprimentoRef: number | string;
qtdComponentesFilhos: number; qtdComponentesFilhos: number;
components?: RawBocadComponent[];
} }
interface RawBocadComponent { interface RawBocadComponent {
@@ -66,14 +71,19 @@ interface RawBocadAssembly {
} }
export const BocadConverter: React.FC = () => { export const BocadConverter: React.FC = () => {
const { importPecas } = usePecas();
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isDirectImporting, setIsDirectImporting] = useState(false);
const [tratamentoGlobal, setTratamentoGlobal] = useState<'pintura' | 'galvanizacao'>('pintura'); const [tratamentoGlobal, setTratamentoGlobal] = useState<'pintura' | 'galvanizacao'>('pintura');
const [extractedPieces, setExtractedPieces] = useState<BocadConvertedPiece[]>([]); const [extractedPieces, setExtractedPieces] = useState<BocadConvertedPiece[]>([]);
const [headerOf, setHeaderOf] = useState<string>(''); const [headerOf, setHeaderOf] = useState<string>('');
const [headerFase, setHeaderFase] = useState<string>(''); const [headerFase, setHeaderFase] = useState<string>('');
const [processingLogs, setProcessingLogs] = useState<string[]>([]); const [processingLogs, setProcessingLogs] = useState<string[]>([]);
const [showLogs, setShowLogs] = useState(false); const [showLogs, setShowLogs] = useState(false);
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -321,11 +331,10 @@ export const BocadConverter: React.FC = () => {
} }
// Critério de identificação da Peça Principal (Linha com Fundo Cinza): // Critério de identificação da Peça Principal (Linha com Fundo Cinza):
// No formato Bocad: // Pela regra do Bocad: Marcas < 1000 são as peças principais (linhas cinzas)
// 1. Linha principal tem 'Marca' preenchida, 'Quant' > 0, 'Perfil' genérico (ex: VIGA) // Marcas >= 1000 são componentes (linhas brancas) que ficam abaixo da peça principal
// 2. E o campo 'Qualid' é VAZIO na linha principal (o aço fica nas linhas de componentes) const marcaNum = parseInt(marcaVal, 10);
// 3. O Peso Total é a somatória do lote da peça const isMainAssemblyRow = !isNaN(marcaNum) && marcaNum < 1000 && marcaVal && quantVal > 0;
const isMainAssemblyRow = !qualidVal && marcaVal && quantVal > 0 && perfilVal;
if (isMainAssemblyRow) { if (isMainAssemblyRow) {
// Se já havia um subconjunto sendo processado, salva-o // Se já havia um subconjunto sendo processado, salva-o
@@ -411,7 +420,8 @@ export const BocadConverter: React.FC = () => {
material: normalizedMat, material: normalizedMat,
perfilPrincipal: perfilPrincipal, perfilPrincipal: perfilPrincipal,
comprimentoRef: compRef, comprimentoRef: compRef,
qtdComponentesFilhos: whiteRowsCount qtdComponentesFilhos: whiteRowsCount,
components: asm.components
}; };
}); });
@@ -516,6 +526,87 @@ export const BocadConverter: React.FC = () => {
} }
}; };
const handleDirectImport = async () => {
if (extractedPieces.length === 0) return;
setIsDirectImporting(true);
try {
const importacaoRows: any[] = [];
extractedPieces.forEach((item) => {
const comprPeca = extrairComprimentoSeguro(item.comprimentoRef) ?? extrairComprimentoSeguro(item);
if (item.compostoPorComponentes === 'SIM' && item.components && item.components.length > 0) {
item.components.forEach((comp) => {
const comprComp = extrairComprimentoComponenteSeguro(comp.compr) ?? extrairComprimentoComponenteSeguro(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,
comprimento: comprPeca,
comprimento_ref: comprPeca,
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,
comprimento_componente: comprComp,
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,
comprimento: comprPeca,
comprimento_ref: comprPeca,
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
});
}
});
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(importacaoRows);
const { componentesMap } = consolidarPecasEComponentes(importacaoRows);
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsDirectImporting(false);
return;
}
await importPecas(importacaoRows);
toast.success(`${extractedPieces.length} peças foram importadas com sucesso!`);
addLog(`✅ Importação concluída: ${extractedPieces.length} peças gravadas no banco.`);
} catch (err: any) {
console.error('Erro na importação direta:', err);
toast.error(`Falha ao importar: ${err?.message || 'Erro no banco'}`);
} finally {
setIsDirectImporting(false);
}
};
const totalPesoGeral = extractedPieces.reduce((acc, curr) => acc + (curr.pesoTotal || 0), 0); const totalPesoGeral = extractedPieces.reduce((acc, curr) => acc + (curr.pesoTotal || 0), 0);
const totalPecasCompostas = extractedPieces.filter((p) => p.compostoPorComponentes === 'SIM').length; const totalPecasCompostas = extractedPieces.filter((p) => p.compostoPorComponentes === 'SIM').length;
const totalPecasSimples = extractedPieces.filter((p) => p.compostoPorComponentes === 'NAO').length; const totalPecasSimples = extractedPieces.filter((p) => p.compostoPorComponentes === 'NAO').length;
@@ -690,11 +781,30 @@ export const BocadConverter: React.FC = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
onClick={exportToOfficialExcel} onClick={exportToOfficialExcel}
className="bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs px-4 h-9 shadow-lg shadow-emerald-950/40" variant="outline"
className="border-emerald-600/60 text-emerald-400 hover:bg-emerald-600/10 font-semibold text-xs px-3.5 h-9"
> >
<Download className="w-4 h-4 mr-1.5" /> <Download className="w-4 h-4 mr-1.5" />
Baixar Planilha Padrão (.xlsx) Baixar Planilha Padrão (.xlsx)
</Button> </Button>
<Button
onClick={handleDirectImport}
disabled={isDirectImporting}
className="bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs px-4 h-9 shadow-lg shadow-indigo-950/40"
>
{isDirectImporting ? (
<>
<RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />
Gravando no Banco...
</>
) : (
<>
<Upload className="w-4 h-4 mr-1.5" />
cadastrar peças na OF
</>
)}
</Button>
</div> </div>
</div> </div>
)} )}
@@ -783,6 +893,19 @@ export const BocadConverter: React.FC = () => {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Modal de Detecção de Conflitos e Conciliação */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={headerOf || ''}
onConcluido={() => {
setShowDiffModal(false);
addLog(`✅ Importação conciliada concluída com sucesso.`);
}}
/>
</div> </div>
); );
}; };
@@ -0,0 +1,385 @@
import React, { useState, useEffect } 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 { ScrollArea } from '@/components/ui/scroll-area';
import {
Truck,
Calendar,
AlertTriangle,
CheckCircle2,
Loader2,
Sparkles,
Info
} from 'lucide-react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/hooks/useAuth';
import {
validarRomaneioParaEntrega,
executarApontamentoRomaneioEntregue,
ResultadoValidacaoEntrega
} from '@/utils/validacaoRomaneio';
export interface RomaneioEntregaInfo {
id: string;
numero_romaneio: string;
of_number: string;
data_prevista_entrega?: string | null;
data_romaneio?: string | null;
status?: string;
}
interface ConfirmacaoEntregaRomaneioModalProps {
isOpen: boolean;
onClose: () => void;
romaneio: RomaneioEntregaInfo | null;
onSucesso?: () => void;
}
export const ConfirmacaoEntregaRomaneioModal: React.FC<ConfirmacaoEntregaRomaneioModalProps> = ({
isOpen,
onClose,
romaneio,
onSucesso
}) => {
const { user } = useAuth();
const queryClient = useQueryClient();
const [loadingAnalise, setLoadingAnalise] = useState(false);
const [analise, setAnalise] = useState<ResultadoValidacaoEntrega | null>(null);
const [dataRealEntrega, setDataRealEntrega] = useState<string>('');
const [processando, setProcessando] = useState(false);
// Inicializar dados ao abrir
useEffect(() => {
if (isOpen && romaneio) {
const hoje = new Date().toISOString().split('T')[0];
const dataInicial = romaneio.data_prevista_entrega || hoje;
setDataRealEntrega(dataInicial);
carregarAnalise(romaneio.id, romaneio.of_number);
} else {
setAnalise(null);
setDataRealEntrega('');
}
}, [isOpen, romaneio]);
const carregarAnalise = async (romaneioId: string, ofNumber: string) => {
try {
setLoadingAnalise(true);
const res = await validarRomaneioParaEntrega(romaneioId, ofNumber);
setAnalise(res);
} catch (e: any) {
console.error('Erro ao analisar romaneio:', e);
toast.error('Erro ao verificar status das peças: ' + (e?.message || 'Falha de comunicação'));
} finally {
setLoadingAnalise(false);
}
};
const handleExecutar = async (autorizarCascata: boolean, apontarApenasLiberadas: boolean) => {
if (!romaneio) return;
if (!dataRealEntrega) {
toast.error('Informe a data real de entrega para gravar o apontamento');
return;
}
try {
setProcessando(true);
const res = await executarApontamentoRomaneioEntregue({
romaneioId: romaneio.id,
ofNumber: romaneio.of_number,
numeroRomaneio: romaneio.numero_romaneio,
dataEntregaReal: dataRealEntrega,
autorizarCascata,
apontarApenasLiberadas,
userId: user?.id
});
if (res.sucesso) {
toast.success(res.mensagem);
// Atualizar queries ativas para atualizar tabelas e gráficos
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['romaneios'] }),
queryClient.invalidateQueries({ queryKey: ['apontamentos'] }),
queryClient.invalidateQueries({ queryKey: ['dashboard-producao'] }),
queryClient.invalidateQueries({ queryKey: ['pecas'] })
]);
onClose();
if (onSucesso) onSucesso();
} else {
toast.warning(res.mensagem);
}
} catch (e: any) {
console.error('Erro ao processar entrega:', e);
toast.error('Erro ao concluir apontamento: ' + (e?.message || 'Falha no banco de dados'));
} finally {
setProcessando(false);
}
};
if (!romaneio) return null;
const dataPrevistaFormatada = romaneio.data_prevista_entrega
? new Date(romaneio.data_prevista_entrega + 'T12:00:00').toLocaleDateString('pt-BR')
: null;
const hoje = new Date().toISOString().split('T')[0];
const dataDiferenteDePrevista = romaneio.data_prevista_entrega && dataRealEntrega !== romaneio.data_prevista_entrega;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col p-6">
<DialogHeader className="border-b pb-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Truck className="h-6 w-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<span>Confirmar Entrega & Apontamento de Expedição</span>
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm text-muted-foreground mt-0.5">
Romaneio <strong>{romaneio.numero_romaneio}</strong> • OF <strong>{romaneio.of_number}</strong>
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="py-4 space-y-4 overflow-y-auto flex-1 pr-1">
{/* Seção 1: Data Real da Entrega */}
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Label htmlFor="data-real-entrega" className="font-semibold text-sm flex items-center gap-2">
<Calendar className="h-4 w-4 text-primary" />
Data Real da Entrega *
</Label>
<div className="flex items-center gap-1.5">
{romaneio.data_prevista_entrega && (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => setDataRealEntrega(romaneio.data_prevista_entrega!)}
>
Usar Data Prevista ({dataPrevistaFormatada})
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => setDataRealEntrega(hoje)}
>
Usar Hoje
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
<Input
id="data-real-entrega"
type="date"
value={dataRealEntrega}
onChange={(e) => setDataRealEntrega(e.target.value)}
className="h-9 font-medium"
/>
<div className="text-xs text-muted-foreground">
{dataDiferenteDePrevista ? (
<span className="text-amber-600 dark:text-amber-400 font-medium flex items-center gap-1">
<Info className="h-3.5 w-3.5 flex-shrink-0" />
Data real difere da data prevista ({dataPrevistaFormatada}).
</span>
) : (
<span>Os apontamentos serão gravados exatamente nesta data.</span>
)}
</div>
</div>
</div>
{/* Seção 2: Diagnóstico dos Processos de Fabricação */}
{loadingAnalise ? (
<div className="p-8 text-center space-y-2">
<Loader2 className="h-7 w-7 animate-spin mx-auto text-primary" />
<p className="text-xs text-muted-foreground">Analisando processos de fabricação das peças...</p>
</div>
) : analise ? (
<div className="space-y-3">
{analise.totalPendentes === 0 ? (
// Cenário A: Todas as peças liberadas
<div className="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-lg flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<h4 className="font-semibold text-emerald-700 dark:text-emerald-300 text-sm">
Tudo pronto para Expedição!
</h4>
<p className="text-xs text-muted-foreground">
Todas as <strong>{analise.totalItens} peças</strong> deste romaneio já concluíram os processos de fabricação anteriores na fábrica. Ao confirmar, o apontamento de Expedição será registrado imediatamente para todas as unidades na data informada.
</p>
</div>
</div>
) : (
// Cenário B: Existem peças com pendências nos processos anteriores
<div className="space-y-3">
<div className="p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<h4 className="font-semibold text-amber-700 dark:text-amber-300 text-sm">
Peças Pendentes nos Processos Anteriores ({analise.totalPendentes} de {analise.totalItens} itens)
</h4>
<p className="text-xs text-muted-foreground">
Algumas peças deste romaneio ainda não possuem apontamentos completos nos processos de fabricação anteriores (Corte, Solda ou Pintura/Galv).
</p>
</div>
</div>
{/* Tabela de Peças Pendentes */}
<div className="border rounded-md overflow-hidden bg-card">
<div className="bg-muted/50 px-3 py-2 text-xs font-semibold text-foreground border-b flex justify-between items-center">
<span>Detalhamento das Peças com Pendência</span>
<Badge variant="outline" className="text-xs">
{analise.totalPendentes} peça(s) a regularizar
</Badge>
</div>
<ScrollArea className="max-h-[220px]">
<table className="w-full text-xs text-left">
<thead className="bg-muted/30 text-muted-foreground uppercase border-b sticky top-0">
<tr>
<th className="py-2 px-3">Marca</th>
<th className="py-2 px-3 text-center">Fase</th>
<th className="py-2 px-3 text-center">No Romaneio</th>
<th className="py-2 px-3">Processos que Faltam Apontar</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{analise.pecasAnalise
?.filter(p => !p.liberada)
.map((peca) => (
<tr key={peca.peca_id} className="hover:bg-accent/40">
<td className="py-2 px-3 font-semibold text-foreground">
{peca.marca}
</td>
<td className="py-2 px-3 text-center">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
Fase {peca.fase}
</Badge>
</td>
<td className="py-2 px-3 text-center font-bold">
{peca.quantidade_expedida} un
</td>
<td className="py-2 px-3">
<div className="flex flex-wrap gap-1">
{peca.processosFaltantes.map((f, i) => (
<Badge
key={i}
variant="outline"
className="text-[10px] bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
>
{f.nome}: faltam {f.qtdFaltante} un
</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
</div>
{/* Pergunta de Decisão com Destaque */}
<div className="p-3.5 bg-blue-500/10 border border-blue-500/30 rounded-lg flex items-center gap-3">
<Sparkles className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="text-xs">
<p className="font-semibold text-blue-900 dark:text-blue-200">
Autorizar Apontamento em Cascata?
</p>
<p className="text-muted-foreground mt-0.5">
O sistema irá criar automaticamente os apontamentos faltantes nos processos pendentes para regularizar a produção e registrar a entrega completa de todas as peças na Expedição.
</p>
</div>
</div>
</div>
)}
</div>
) : null}
</div>
<DialogFooter className="border-t pt-3 flex flex-wrap gap-2 justify-between items-center sm:justify-between">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={processando}
size="sm"
>
Cancelar
</Button>
<div className="flex flex-wrap gap-2 ml-auto">
{analise && analise.totalPendentes === 0 ? (
// Botão para caso sem pendências
<Button
type="button"
onClick={() => handleExecutar(false, false)}
disabled={processando || !dataRealEntrega}
className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold text-xs shadow-sm"
size="sm"
>
{processando ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 mr-1.5" />
)}
Confirmar Entrega & Apontar Expedição
</Button>
) : analise && analise.totalPendentes > 0 ? (
<>
{analise.totalLiberadas && analise.totalLiberadas > 0 ? (
<Button
type="button"
variant="secondary"
onClick={() => handleExecutar(false, true)}
disabled={processando || !dataRealEntrega}
className="text-xs"
size="sm"
title="Aponta somente as peças que já concluíram Pintura"
>
Apontar Apenas Liberadas ({analise.totalLiberadas})
</Button>
) : null}
<Button
type="button"
onClick={() => handleExecutar(true, false)}
disabled={processando || !dataRealEntrega}
className="bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs shadow-sm flex items-center gap-1.5"
size="sm"
>
{processando ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
Autorizar Cascata e Apontar Todas ({analise.totalItens} peças)
</Button>
</>
) : null}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+124 -30
View File
@@ -28,7 +28,10 @@ interface PecaDisponivel {
id: string; id: string;
marca: string; marca: string;
descricao: string; descricao: string;
perfil_principal?: string | null;
etapa_fase: string; etapa_fase: string;
quantidade_total_peca: number;
quantidade_ja_expedida: number;
quantidade_disponivel: number; quantidade_disponivel: number;
peso_unitario: number; peso_unitario: number;
comprimento?: number | null; comprimento?: number | null;
@@ -104,18 +107,24 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
.from('apontamentos_producao') .from('apontamentos_producao')
.select(` .select(`
peca_id, peca_id,
componente_id,
tipo_apontamento,
quantidade_produzida, quantidade_produzida,
processo_id, processo_id,
processo:processos_fabricacao!inner!apontamentos_producao_processo_id_fkey(nome), processo:processos_fabricacao!inner!apontamentos_producao_processo_id_fkey(nome),
componente:componentes_peca!apontamentos_producao_componente_id_fkey(id, quantidade_por_peca),
peca:pecas!inner!apontamentos_producao_peca_id_fkey( peca:pecas!inner!apontamentos_producao_peca_id_fkey(
id, id,
marca, marca,
descricao, descricao,
perfil_principal,
etapa_fase, etapa_fase,
quantidade,
peso_unitario, peso_unitario,
comprimento, comprimento,
prioridade, prioridade,
of_number of_number,
tem_componentes
) )
`) `)
.eq('peca.of_number', romaneio.of_number) .eq('peca.of_number', romaneio.of_number)
@@ -141,49 +150,99 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(expedidosData as any[]).forEach((item: any) => { (expedidosData as any[]).forEach((item: any) => {
const pid = item.peca_id; const pid = item.peca_id;
const qtd = item.quantidade_expedida || 0; const qtd = Number(item.quantidade_expedida) || 0;
const current = expedidosMap.get(pid) || 0; const current = expedidosMap.get(pid) || 0;
expedidosMap.set(pid, current + qtd); expedidosMap.set(pid, current + qtd);
}); });
} }
// Agrupar por peça e calcular quantidade total produzida // 3. Agrupar por peça principal (NUNCA somar unidades de subcomponentes na peça principal)
const pecasAgrupadas = new Map<string, PecaDisponivel>(); const pecasAgrupadas = new Map<
string,
{
peca: any;
qtdPecaDireta: number;
componentesCortados: Map<string, { cortado: number; porPeca: number }>;
}
>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(apontamentosData as any[])?.forEach((apontamento: any) => { (apontamentosData as any[])?.forEach((apontamento: any) => {
const pecaId = apontamento.peca?.id; const pecaObj = apontamento.peca;
const pecaId = pecaObj?.id;
if (!pecaId) return; if (!pecaId) return;
const quantidade = apontamento.quantidade_produzida || 0; if (!pecasAgrupadas.has(pecaId)) {
if (pecasAgrupadas.has(pecaId)) {
const pecaExistente = pecasAgrupadas.get(pecaId)!;
pecaExistente.quantidade_disponivel += quantidade; // Aqui salva o Bruto temporariamente
} else {
pecasAgrupadas.set(pecaId, { pecasAgrupadas.set(pecaId, {
id: apontamento.peca.id, peca: pecaObj,
marca: apontamento.peca.marca, qtdPecaDireta: 0,
descricao: apontamento.peca.descricao || '', componentesCortados: new Map()
etapa_fase: apontamento.peca.etapa_fase || '',
quantidade_disponivel: quantidade, // Bruto temporário
peso_unitario: apontamento.peca.peso_unitario || 0,
comprimento: apontamento.peca.comprimento || null,
prioridade: apontamento.peca.prioridade || 'P4'
}); });
} }
const grupo = pecasAgrupadas.get(pecaId)!;
const quantidade = Number(apontamento.quantidade_produzida) || 0;
const isComponente =
apontamento.tipo_apontamento === 'componente' || Boolean(apontamento.componente_id);
if (isComponente) {
const compId = apontamento.componente_id || 'comp_default';
const porPeca = Math.max(1, Number(apontamento.componente?.quantidade_por_peca) || 1);
const atualComp = grupo.componentesCortados.get(compId)?.cortado || 0;
grupo.componentesCortados.set(compId, {
cortado: atualComp + quantidade,
porPeca
});
} else {
grupo.qtdPecaDireta += quantidade;
}
}); });
// 4. Calcular o saldo Real (Produzido - Expedido) e limpar zerados // 4. Calcular o saldo Real da peça principal (Produzido da Peça Principal - Expedido em Romaneios)
const pecasDisponiveisFinal: PecaDisponivel[] = []; const pecasDisponiveisFinal: PecaDisponivel[] = [];
pecasAgrupadas.forEach((peca, pecaId) => { pecasAgrupadas.forEach((grupo, pecaId) => {
const pecaObj = grupo.peca;
const qtdTotalPeca = Math.max(1, Number(pecaObj.quantidade) || 1);
let qtdProduzidaPecaPrincipal = 0;
if (grupo.qtdPecaDireta > 0) {
// Apontamento direto da peça principal (ou kit sincronizado)
qtdProduzidaPecaPrincipal = Math.min(qtdTotalPeca, grupo.qtdPecaDireta);
} else if (grupo.componentesCortados.size > 0) {
// Caso haja apenas apontamentos de subcomponentes no processo (ex: Corte antes da soldagem),
// converte para a quantidade equivalente da peça principal (limitada à quantidade total da peça na OF)
let maxPecasEquivalentes = 0;
grupo.componentesCortados.forEach(({ cortado, porPeca }) => {
const equivalentes = Math.floor(cortado / porPeca);
if (equivalentes > maxPecasEquivalentes) {
maxPecasEquivalentes = equivalentes;
}
});
qtdProduzidaPecaPrincipal = Math.min(
qtdTotalPeca,
maxPecasEquivalentes > 0 ? maxPecasEquivalentes : qtdTotalPeca
);
}
const qtdExpedida = expedidosMap.get(pecaId) || 0; const qtdExpedida = expedidosMap.get(pecaId) || 0;
const saldoReal = peca.quantidade_disponivel - qtdExpedida; const saldoReal = Math.min(
qtdTotalPeca - qtdExpedida,
qtdProduzidaPecaPrincipal - qtdExpedida
);
if (saldoReal > 0) { if (saldoReal > 0) {
pecasDisponiveisFinal.push({ pecasDisponiveisFinal.push({
...peca, id: pecaObj.id,
quantidade_disponivel: saldoReal // Retorna apenas o saldo disponível marca: pecaObj.marca,
descricao: pecaObj.descricao || '',
perfil_principal: pecaObj.perfil_principal || null,
etapa_fase: pecaObj.etapa_fase || '',
quantidade_total_peca: qtdTotalPeca,
quantidade_ja_expedida: qtdExpedida,
quantidade_disponivel: saldoReal,
peso_unitario: Number(pecaObj.peso_unitario) || 0,
comprimento: pecaObj.comprimento || null,
prioridade: pecaObj.prioridade || 'P4'
}); });
} }
}); });
@@ -344,6 +403,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
quantidade_expedida: peca.quantidade_disponivel, quantidade_expedida: peca.quantidade_disponivel,
peso_unitario: peca.peso_unitario, peso_unitario: peca.peso_unitario,
peso_total: peca.quantidade_disponivel * peca.peso_unitario, peso_total: peca.quantidade_disponivel * peca.peso_unitario,
comprimento: peca.comprimento || null,
marca: peca.marca, marca: peca.marca,
descricao: peca.descricao, descricao: peca.descricao,
fase: peca.etapa_fase fase: peca.etapa_fase
@@ -400,6 +460,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
quantidade_expedida: quantidade, quantidade_expedida: quantidade,
peso_unitario: pecaSelecionada.peso_unitario, peso_unitario: pecaSelecionada.peso_unitario,
peso_total: quantidade * pecaSelecionada.peso_unitario, peso_total: quantidade * pecaSelecionada.peso_unitario,
comprimento: pecaSelecionada.comprimento || null,
marca: pecaSelecionada.marca, marca: pecaSelecionada.marca,
descricao: pecaSelecionada.descricao, descricao: pecaSelecionada.descricao,
fase: pecaSelecionada.etapa_fase fase: pecaSelecionada.etapa_fase
@@ -499,6 +560,10 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
const qtdTotalPecas = itensPeca.reduce((total, item) => total + item.quantidade, 0); const qtdTotalPecas = itensPeca.reduce((total, item) => total + item.quantidade, 0);
const pesoTotalPecas = itensPeca.reduce((total, item) => total + item.peso_total, 0); const pesoTotalPecas = itensPeca.reduce((total, item) => total + item.peso_total, 0);
const maiorComprimentoPecas = itensPeca.reduce(
(max, item) => Math.max(max, Number(item.comprimento) || 0),
0
);
return ( return (
<> <>
@@ -629,7 +694,16 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
<Label className={`font-medium mb-2 block ${isMobile ? 'text-sm' : 'text-sm'}`}> <Label className={`font-medium mb-2 block ${isMobile ? 'text-sm' : 'text-sm'}`}>
Peça Disponível Peça Disponível
</Label> </Label>
<Select value={pecaDisponivel} onValueChange={setPecaDisponivel}> <Select
value={pecaDisponivel}
onValueChange={(val) => {
setPecaDisponivel(val);
const pSel = pecasDisponiveis.find((p) => p.id === val);
if (pSel) {
setQuantidadePeca(String(pSel.quantidade_disponivel));
}
}}
>
<SelectTrigger className={isMobile ? 'h-10' : 'h-11'}> <SelectTrigger className={isMobile ? 'h-10' : 'h-11'}>
<SelectValue placeholder="Selecionar peça" /> <SelectValue placeholder="Selecionar peça" />
</SelectTrigger> </SelectTrigger>
@@ -646,13 +720,30 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
pecasFiltradas.map((peca) => { pecasFiltradas.map((peca) => {
const limite = romaneio.comprimento_maximo_veiculo || 12000; const limite = romaneio.comprimento_maximo_veiculo || 12000;
const naoCabe = Boolean(peca.comprimento && peca.comprimento > limite); const naoCabe = Boolean(peca.comprimento && peca.comprimento > limite);
const descClean = (peca.descricao || '').trim();
const perfilClean = (peca.perfil_principal || '').trim();
const descricaoCompleta =
descClean && perfilClean && descClean.toLowerCase() !== perfilClean.toLowerCase()
? `${descClean} - ${perfilClean}`
: descClean || perfilClean || 'Peça Principal';
const faseFormatada = peca.etapa_fase
? peca.etapa_fase.toLowerCase().startsWith('fase')
? peca.etapa_fase
: `Fase ${peca.etapa_fase}`
: '';
const infoSaldo =
peca.quantidade_ja_expedida > 0
? `Disp: ${peca.quantidade_disponivel} de ${peca.quantidade_total_peca} un`
: `Qtd Disp: ${peca.quantidade_disponivel} un`;
return ( return (
<SelectItem key={peca.id} value={peca.id}> <SelectItem key={peca.id} value={peca.id}>
<div className="flex items-center justify-between gap-2 w-full"> <div className="flex items-center justify-between gap-2 w-full">
<span className="truncate"> <span className="truncate font-medium">
{peca.marca} - {peca.descricao} (Qtd: {peca.quantidade_disponivel}) {peca.marca}
{faseFormatada ? ` [${faseFormatada}]` : ''} - {descricaoCompleta} ({infoSaldo})
</span> </span>
{peca.comprimento && ( {peca.comprimento != null && Number(peca.comprimento) > 0 && (
<span className={`text-[11px] font-mono shrink-0 ml-2 px-1.5 py-0.5 rounded ${ <span className={`text-[11px] font-mono shrink-0 ml-2 px-1.5 py-0.5 rounded ${
naoCabe ? 'bg-destructive/20 text-destructive font-bold' : 'text-muted-foreground bg-muted' naoCabe ? 'bg-destructive/20 text-destructive font-bold' : 'text-muted-foreground bg-muted'
}`}> }`}>
@@ -733,6 +824,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
<Badge variant="outline" className={isMobile ? 'text-xs px-2 py-1' : ''}> <Badge variant="outline" className={isMobile ? 'text-xs px-2 py-1' : ''}>
Peso Total: {pesoTotalPecas.toFixed(2)} kg Peso Total: {pesoTotalPecas.toFixed(2)} kg
</Badge> </Badge>
<Badge variant="outline" className={isMobile ? 'text-xs px-2 py-1' : ''}>
Maior Comprimento: {maiorComprimentoPecas > 0 ? `${maiorComprimentoPecas} mm` : '-'}
</Badge>
</div> </div>
</div> </div>
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Edit2, Trash2, X } from 'lucide-react'; import { Edit2, Trash2, Check, X, Package } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { RomaneioExpedicao } from '@/hooks/useRomaneios'; import { RomaneioExpedicao } from '@/hooks/useRomaneios';
@@ -20,6 +21,7 @@ interface ItemRomaneioPeca {
id: string; id: string;
peca_id: string; peca_id: string;
quantidade_expedida: number; quantidade_expedida: number;
quantidade_max_peca?: number;
peso_unitario: number; peso_unitario: number;
peso_total: number; peso_total: number;
comprimento?: number | null; comprimento?: number | null;
@@ -39,13 +41,16 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
const [itensPeca, setItensPeca] = useState<ItemRomaneioPeca[]>([]); const [itensPeca, setItensPeca] = useState<ItemRomaneioPeca[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editingItemId, setEditingItemId] = useState<string | null>(null);
const [editQuantidade, setEditQuantidade] = useState<string>('');
const [savingEdit, setSavingEdit] = useState(false);
const carregarItensPecas = async () => { const carregarItensPecas = async () => {
setLoading(true); setLoading(true);
try { try {
const { data, error } = await supabase const { data, error } = await supabase
.from('itens_romaneio_pecas') .from('itens_romaneio_pecas')
.select('*, pecas(comprimento)') .select('*, pecas(comprimento, prioridade, quantidade)')
.eq('romaneio_id', romaneio.id) .eq('romaneio_id', romaneio.id)
.order('marca'); .order('marca');
@@ -56,13 +61,14 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
id: item.id, id: item.id,
peca_id: item.peca_id, peca_id: item.peca_id,
quantidade_expedida: item.quantidade_expedida, quantidade_expedida: item.quantidade_expedida,
quantidade_max_peca: item.pecas?.quantidade || undefined,
peso_unitario: item.peso_unitario, peso_unitario: item.peso_unitario,
peso_total: item.peso_total, peso_total: item.peso_total,
comprimento: item.pecas?.comprimento || item.comprimento || null, comprimento: item.pecas?.comprimento || item.comprimento || null,
marca: item.marca, marca: item.marca,
descricao: item.descricao || '', descricao: item.descricao || '',
fase: item.fase || '', fase: item.fase || '',
prioridade: 'P4' prioridade: item.pecas?.prioridade || 'P4'
})); }));
setItensPeca(itensFormatados); setItensPeca(itensFormatados);
} }
@@ -76,8 +82,10 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setEditingItemId(null);
carregarItensPecas(); carregarItensPecas();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, romaneio.id]); }, [isOpen, romaneio.id]);
const handleRemoverItem = async (itemId: string) => { const handleRemoverItem = async (itemId: string) => {
@@ -99,21 +107,68 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
}; };
const handleEditarItem = (item: ItemRomaneioPeca) => { const handleEditarItem = (item: ItemRomaneioPeca) => {
// Funcionalidade de edição pode ser implementada aqui setEditingItemId(item.id);
toast.info('Funcionalidade de edição em desenvolvimento'); setEditQuantidade(String(item.quantidade_expedida));
};
const handleCancelarEdicao = () => {
setEditingItemId(null);
setEditQuantidade('');
};
const handleSalvarEdicao = async (item: ItemRomaneioPeca) => {
const novaQtd = parseInt(editQuantidade, 10);
if (isNaN(novaQtd) || novaQtd <= 0) {
toast.error('Informe uma quantidade válida maior que zero');
return;
}
if (item.quantidade_max_peca && novaQtd > item.quantidade_max_peca) {
toast.error(`A quantidade não pode exceder o total da peça na OF (${item.quantidade_max_peca} un)`);
return;
}
try {
setSavingEdit(true);
const novoPesoTotal = novaQtd * item.peso_unitario;
const { error } = await supabase
.from('itens_romaneio_pecas')
.update({
quantidade_expedida: novaQtd,
peso_total: novoPesoTotal
})
.eq('id', item.id);
if (error) throw error;
toast.success(`Quantidade da peça ${item.marca} atualizada para ${novaQtd} un`);
setEditingItemId(null);
setEditQuantidade('');
carregarItensPecas();
onRefresh();
} catch (error) {
console.error('Erro ao atualizar item:', error);
toast.error('Erro ao atualizar quantidade da peça');
} finally {
setSavingEdit(false);
}
}; };
const getPrioridadeColor = (prioridade: string) => { const getPrioridadeColor = (prioridade: string) => {
switch (prioridade) { switch (prioridade) {
case 'P1': return 'bg-red-100 text-red-800'; case 'P1': return 'bg-red-100 text-red-800 border-red-200 dark:bg-red-950/60 dark:text-red-300 dark:border-red-800';
case 'P2': return 'bg-orange-100 text-orange-800'; case 'P2': return 'bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-950/60 dark:text-orange-300 dark:border-orange-800';
case 'P3': return 'bg-blue-100 text-blue-800'; case 'P3': return 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950/60 dark:text-blue-300 dark:border-blue-800';
default: return 'bg-gray-100 text-gray-800'; default: return 'bg-slate-100 text-slate-800 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700';
} }
}; };
const qtdTotal = itensPeca.reduce((total, item) => total + item.quantidade_expedida, 0); const qtdTotal = itensPeca.reduce((total, item) => total + item.quantidade_expedida, 0);
const pesoTotal = itensPeca.reduce((total, item) => total + item.peso_total, 0); const pesoTotal = itensPeca.reduce((total, item) => total + item.peso_total, 0);
const maiorComprimento = itensPeca.reduce(
(max, item) => Math.max(max, Number(item.comprimento) || 0),
0
);
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
@@ -122,47 +177,58 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
? 'w-[95vw] max-w-none h-[95vh] m-2' ? 'w-[95vw] max-w-none h-[95vh] m-2'
: isTablet : isTablet
? 'w-[90vw] max-w-5xl h-[90vh]' ? 'w-[90vw] max-w-5xl h-[90vh]'
: 'max-w-6xl h-[95vh]' : 'max-w-6xl h-[90vh]'
} }
bg-slate-900 text-white border-slate-700 overflow-hidden flex flex-col bg-background text-foreground border-border shadow-xl overflow-hidden flex flex-col
`}> `}>
<DialogHeader className="border-b border-slate-700 pb-3 shrink-0"> <DialogHeader className="border-b border-border pb-3 shrink-0 pr-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<DialogTitle className={`font-bold text-white ${isMobile ? 'text-lg' : 'text-xl'}`}> <DialogTitle className={`font-bold text-foreground flex items-center gap-2 ${isMobile ? 'text-lg' : 'text-xl'}`}>
<Package className="h-5 w-5 text-primary shrink-0" />
<span className="truncate"> <span className="truncate">
Peças do Romaneio {romaneio.numero_romaneio} - OF: {romaneio.of_number} Peças do Romaneio {romaneio.numero_romaneio} - OF: {romaneio.of_number}
</span> </span>
</DialogTitle> </DialogTitle>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className={`text-gray-400 hover:text-white p-0 ${isMobile ? 'h-6 w-6' : 'h-8 w-8'}`}
>
<X className={isMobile ? 'h-3 w-3' : 'h-4 w-4'} />
</Button>
</div> </div>
<div className={`flex gap-2 mt-3 ${isMobile ? 'flex-wrap' : ''}`}> <div className={`flex gap-2 mt-3 ${isMobile ? 'flex-wrap' : ''}`}>
<Badge variant="outline" className={`border-blue-500 text-blue-400 ${isMobile ? 'text-xs px-2 py-1' : ''}`}> <Badge
variant="outline"
className={`border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-400 dark:border-blue-500/50 font-semibold ${
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
}`}
>
Qtd Total: {qtdTotal} Qtd Total: {qtdTotal}
</Badge> </Badge>
<Badge variant="outline" className={`border-green-500 text-green-400 ${isMobile ? 'text-xs px-2 py-1' : ''}`}> <Badge
variant="outline"
className={`border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 dark:border-emerald-500/50 font-semibold ${
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
}`}
>
Peso Total: {pesoTotal.toFixed(2)} kg Peso Total: {pesoTotal.toFixed(2)} kg
</Badge> </Badge>
<Badge
variant="outline"
className={`border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400 dark:border-amber-500/50 font-semibold ${
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
}`}
>
Maior Comprimento: {maiorComprimento > 0 ? `${maiorComprimento} mm` : '-'}
</Badge>
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{loading ? ( {loading ? (
<div className="flex justify-center items-center h-64"> <div className="flex justify-center items-center h-64">
<div className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-base'}`}> <div className={`text-muted-foreground ${isMobile ? 'text-sm' : 'text-base'}`}>
Carregando... Carregando...
</div> </div>
</div> </div>
) : itensPeca.length === 0 ? ( ) : itensPeca.length === 0 ? (
<div className="flex justify-center items-center h-64"> <div className="flex justify-center items-center h-64">
<div className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-base'}`}> <div className={`text-muted-foreground ${isMobile ? 'text-sm' : 'text-base'}`}>
Nenhuma peça encontrada no romaneio Nenhuma peça encontrada no romaneio
</div> </div>
</div> </div>
@@ -171,7 +237,7 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
<div className="space-y-1 p-1 pb-4"> <div className="space-y-1 p-1 pb-4">
{/* Cabeçalho da tabela - Desktop/Tablet */} {/* Cabeçalho da tabela - Desktop/Tablet */}
{!isMobile && ( {!isMobile && (
<div className={`grid gap-3 font-medium text-gray-400 bg-slate-800 p-3 rounded-t-md sticky top-0 z-10 ${ <div className={`grid gap-3 font-semibold text-muted-foreground bg-muted/80 dark:bg-slate-800/90 border border-border p-3 rounded-t-md sticky top-0 z-10 backdrop-blur-sm ${
isTablet ? 'grid-cols-9 text-xs' : 'grid-cols-11 text-xs' isTablet ? 'grid-cols-9 text-xs' : 'grid-cols-11 text-xs'
}`}> }`}>
<div>Marca</div> <div>Marca</div>
@@ -190,120 +256,209 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
{itensPeca.map((item, index) => { {itensPeca.map((item, index) => {
const limite = romaneio.comprimento_maximo_veiculo || 12000; const limite = romaneio.comprimento_maximo_veiculo || 12000;
const naoCabe = Boolean(item.comprimento && item.comprimento > limite); const naoCabe = Boolean(item.comprimento && item.comprimento > limite);
const isEditing = editingItemId === item.id;
return ( return (
<div key={item.id}> <div key={item.id}>
{isMobile ? ( {isMobile ? (
// Layout mobile - card style // Layout mobile - card style
<div className={`p-3 border rounded-md mb-2 ${ <div className={`p-3 border rounded-md mb-2 transition-colors ${
naoCabe ? 'border-red-500/50 bg-red-950/20' : 'border-slate-700' naoCabe
? 'border-destructive/50 bg-destructive/10'
: 'border-border'
} ${ } ${
index % 2 === 0 ? 'bg-slate-800/20' : 'bg-slate-800/40' index % 2 === 0 ? 'bg-card' : 'bg-muted/30'
}`}> }`}>
<div className="flex justify-between items-start mb-2"> <div className="flex justify-between items-start mb-2">
<div className="flex-1"> <div className="flex-1">
<div className="font-medium text-white text-sm flex items-center gap-1.5"> <div className="font-semibold text-foreground text-sm flex items-center gap-1.5">
{item.marca} {item.marca}
{naoCabe && ( {naoCabe && (
<span className="text-[10px] bg-red-600 text-white font-bold px-1.5 py-0.5 rounded"> <span className="text-[10px] bg-destructive text-destructive-foreground font-bold px-1.5 py-0.5 rounded">
Excede Veículo Excede Veículo
</span> </span>
)} )}
</div> </div>
<div className="text-gray-300 text-xs">{item.fase}</div> <div className="text-muted-foreground text-xs">Fase {item.fase}</div>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<Button {isEditing ? (
size="sm" <>
variant="ghost" <Button
onClick={() => handleEditarItem(item)} size="sm"
className="h-6 w-6 p-0 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20" variant="ghost"
> disabled={savingEdit}
<Edit2 className="h-3 w-3" /> onClick={() => handleSalvarEdicao(item)}
</Button> className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
<Button title="Salvar quantidade"
size="sm" >
variant="ghost" <Check className="h-3.5 w-3.5" />
onClick={() => handleRemoverItem(item.id)} </Button>
className="h-6 w-6 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20" <Button
> size="sm"
<Trash2 className="h-3 w-3" /> variant="ghost"
</Button> disabled={savingEdit}
onClick={handleCancelarEdicao}
className="h-7 w-7 p-0 text-muted-foreground hover:bg-muted"
title="Cancelar"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditarItem(item)}
className="h-7 w-7 p-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-500/15"
title="Editar quantidade"
>
<Edit2 className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoverItem(item.id)}
className="h-7 w-7 p-0 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-500/15"
title="Remover peça"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div> </div>
</div> </div>
<div className="text-gray-300 text-xs mb-2 line-clamp-2"> <div className="text-muted-foreground text-xs mb-2 line-clamp-2">
{item.descricao} {item.descricao}
</div> </div>
<div className="grid grid-cols-4 gap-2 text-xs"> <div className="grid grid-cols-4 gap-2 text-xs items-center">
<div> <div>
<div className="text-gray-400">Comp:</div> <div className="text-muted-foreground">Comp:</div>
<div className={`font-mono font-medium ${naoCabe ? 'text-red-400 font-bold' : 'text-gray-300'}`}> <div className={`font-mono font-medium ${naoCabe ? 'text-destructive font-bold' : 'text-foreground'}`}>
{item.comprimento ? `${item.comprimento}mm` : '-'} {item.comprimento ? `${item.comprimento}mm` : '-'}
</div> </div>
</div> </div>
<div> <div>
<div className="text-gray-400">Qtd:</div> <div className="text-muted-foreground">Qtd:</div>
<div className="font-bold text-blue-400">{item.quantidade_expedida}</div> {isEditing ? (
<Input
type="number"
min="1"
max={item.quantidade_max_peca}
value={editQuantidade}
onChange={(e) => setEditQuantidade(e.target.value)}
className="h-7 w-16 text-center text-xs px-1"
autoFocus
/>
) : (
<div className="font-bold text-blue-600 dark:text-blue-400">{item.quantidade_expedida}</div>
)}
</div> </div>
<div> <div>
<div className="text-gray-400">Peso Unit:</div> <div className="text-muted-foreground">Peso Unit:</div>
<div className="text-gray-300">{item.peso_unitario.toFixed(2)} kg</div> <div className="text-foreground">{item.peso_unitario.toFixed(2)} kg</div>
</div> </div>
<div> <div>
<div className="text-gray-400">Peso Total:</div> <div className="text-muted-foreground">Peso Total:</div>
<div className="font-medium text-white">{item.peso_total.toFixed(2)} kg</div> <div className="font-semibold text-foreground">{item.peso_total.toFixed(2)} kg</div>
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
// Layout desktop/tablet - table style // Layout desktop/tablet - table style
<div className={`grid gap-3 text-sm p-3 border-b border-slate-700 hover:bg-slate-800/50 transition-colors ${ <div className={`grid gap-3 items-center text-sm p-3 border-b border-x border-border hover:bg-muted/50 transition-colors ${
index % 2 === 0 ? 'bg-slate-800/20' : '' index % 2 === 0 ? 'bg-card' : 'bg-muted/20'
} ${isTablet ? 'grid-cols-9' : 'grid-cols-11'} ${naoCabe ? 'bg-red-950/20' : ''}`}> } ${isTablet ? 'grid-cols-9' : 'grid-cols-11'} ${naoCabe ? 'bg-destructive/10 border-destructive/40' : ''}`}>
<div className="font-medium text-white truncate flex items-center gap-1"> <div className="font-semibold text-foreground truncate flex items-center gap-1">
{item.marca} {item.marca}
{naoCabe && <span title="Não cabe no veículo" className="text-red-400 text-xs">⚠️</span>} {naoCabe && <span title="Não cabe no veículo" className="text-destructive text-xs">⚠️</span>}
</div> </div>
<div className="text-gray-300 truncate">{item.fase}</div> <div className="text-muted-foreground truncate">{item.fase}</div>
<div className={`text-gray-300 truncate ${isTablet ? 'col-span-1' : 'col-span-2'}`}> <div className={`text-foreground truncate ${isTablet ? 'col-span-1' : 'col-span-2'}`}>
{item.descricao} {item.descricao}
</div> </div>
<div className={`text-right font-mono text-xs ${naoCabe ? 'text-red-400 font-bold' : 'text-gray-300'}`}> <div className={`text-right font-mono text-xs ${naoCabe ? 'text-destructive font-bold' : 'text-muted-foreground'}`}>
{item.comprimento ? `${item.comprimento}` : '-'} {item.comprimento ? `${item.comprimento}` : '-'}
</div> </div>
<div className="text-center font-bold text-blue-400"> <div className="text-center font-bold text-blue-600 dark:text-blue-400 flex justify-center">
{item.quantidade_expedida} {isEditing ? (
<Input
type="number"
min="1"
max={item.quantidade_max_peca}
value={editQuantidade}
onChange={(e) => setEditQuantidade(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSalvarEdicao(item);
if (e.key === 'Escape') handleCancelarEdicao();
}}
className="h-7 w-20 text-center text-xs px-1"
autoFocus
/>
) : (
item.quantidade_expedida
)}
</div> </div>
<div className="text-right text-gray-300"> <div className="text-right text-muted-foreground">
{item.peso_unitario.toFixed(2)} kg {item.peso_unitario.toFixed(2)} kg
</div> </div>
<div className="text-right font-medium text-white"> <div className="text-right font-semibold text-foreground">
{item.peso_total.toFixed(2)} kg {item.peso_total.toFixed(2)} kg
</div> </div>
{!isTablet && ( {!isTablet && (
<div className="flex justify-center"> <div className="flex justify-center">
<Badge className={`text-xs ${getPrioridadeColor(item.prioridade)}`}> <Badge variant="outline" className={`text-xs ${getPrioridadeColor(item.prioridade)}`}>
{item.prioridade} {item.prioridade}
</Badge> </Badge>
</div> </div>
)} )}
<div className={`flex items-center justify-center gap-2 ${isTablet ? 'col-span-2' : 'col-span-2'}`}> <div className={`flex items-center justify-center gap-2 ${isTablet ? 'col-span-2' : 'col-span-2'}`}>
<Button {isEditing ? (
size="sm" <>
variant="ghost" <Button
onClick={() => handleEditarItem(item)} size="sm"
className="h-7 w-7 p-0 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20" variant="ghost"
> disabled={savingEdit}
<Edit2 className="h-3 w-3" /> onClick={() => handleSalvarEdicao(item)}
</Button> className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
<Button title="Salvar quantidade"
size="sm" >
variant="ghost" <Check className="h-3.5 w-3.5" />
onClick={() => handleRemoverItem(item.id)} </Button>
className="h-7 w-7 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20" <Button
> size="sm"
<Trash2 className="h-3 w-3" /> variant="ghost"
</Button> disabled={savingEdit}
onClick={handleCancelarEdicao}
className="h-7 w-7 p-0 text-muted-foreground hover:bg-muted"
title="Cancelar edição"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditarItem(item)}
className="h-7 w-7 p-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-500/15"
title="Editar quantidade"
>
<Edit2 className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoverItem(item.id)}
className="h-7 w-7 p-0 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-500/15"
title="Remover item"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div> </div>
</div> </div>
)} )}
@@ -315,11 +470,11 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
)} )}
</div> </div>
<div className={`flex justify-end pt-3 border-t border-slate-700 shrink-0 ${isMobile ? 'px-1' : ''}`}> <div className={`flex justify-end pt-3 border-t border-border shrink-0 ${isMobile ? 'px-1' : ''}`}>
<Button <Button
variant="outline" variant="outline"
onClick={onClose} onClick={onClose}
className={`border-slate-600 text-gray-300 hover:bg-slate-800 ${isMobile ? 'h-10 px-4 text-sm' : ''}`} className={`font-medium text-foreground border-border hover:bg-accent hover:text-accent-foreground ${isMobile ? 'h-10 px-4 text-sm' : 'px-6'}`}
> >
Fechar Fechar
</Button> </Button>
+34 -51
View File
@@ -10,8 +10,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Save, X, AlertTriangle, Loader2 } from 'lucide-react'; import { Save, X, AlertTriangle, Loader2 } from 'lucide-react';
import { RomaneioExpedicao } from '@/hooks/useRomaneios'; import { RomaneioExpedicao } from '@/hooks/useRomaneios';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { validarRomaneioParaEntrega } from '@/utils/validacaoRomaneio'; import { ConfirmacaoEntregaRomaneioModal } from './ConfirmacaoEntregaRomaneioModal';
import { PendenciasProducaoModal, ItemPendenciaProducao } from './PendenciasProducaoModal';
interface RomaneioFormProps { interface RomaneioFormProps {
romaneio?: RomaneioExpedicao; romaneio?: RomaneioExpedicao;
@@ -118,33 +117,22 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
return Array.from(new Set(list)).filter(Boolean); return Array.from(new Set(list)).filter(Boolean);
}, [ofNumbers, romaneio?.of_number, selectedOF, formData.of_number]); }, [ofNumbers, romaneio?.of_number, selectedOF, formData.of_number]);
// Estados para controle de validação de entrega // Estados para controle de validação e confirmação de entrega
const [validandoEntrega, setValidandoEntrega] = useState(false); const [modalConfirmacaoAberto, setModalConfirmacaoAberto] = useState(false);
const [modalPendenciasAberto, setModalPendenciasAberto] = useState(false);
const [pendenciasEncontradas, setPendenciasEncontradas] = useState<ItemPendenciaProducao[]>([]);
const [mensagemBloqueio, setMensagemBloqueio] = useState<string | undefined>(undefined);
const handleStatusChange = async (novoStatus: any) => { const handleStatusChange = (novoStatus: any) => {
if (novoStatus === 'Entregue') { if (novoStatus === 'Entregue') {
if (!romaneio?.id) { if (!romaneio?.id) {
toast.warning('Novo romaneio deve ser criado como "Em planejamento"', { toast.warning('Novo romaneio deve ser criado como "Em planejamento"', {
description: 'Cadastre o romaneio primeiro, adicione as peças e confirme os apontamentos de produção antes de marcá-lo como entregue.' description: 'Cadastre o romaneio primeiro, adicione as peças e confirme os apontamentos antes de marcá-lo como entregue.'
}); });
setFormData(prev => ({ ...prev, status: 'Em planejamento' })); setFormData(prev => ({ ...prev, status: 'Em planejamento' }));
return; return;
} }
setValidandoEntrega(true); // Abre o modal inteligente de confirmação de entrega
const resultado = await validarRomaneioParaEntrega(romaneio.id, formData.of_number || romaneio.of_number); setModalConfirmacaoAberto(true);
setValidandoEntrega(false); return;
if (!resultado.valido) {
setPendenciasEncontradas(resultado.pendencias || []);
setMensagemBloqueio(resultado.mensagem);
setModalPendenciasAberto(true);
// Não altera o status para Entregue, mantém o anterior
return;
}
} }
setFormData(prev => ({ ...prev, status: novoStatus })); setFormData(prev => ({ ...prev, status: novoStatus }));
@@ -153,24 +141,16 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (formData.status === 'Entregue') { if (formData.status === 'Entregue' && romaneio?.status !== 'Entregue') {
if (!romaneio?.id) { if (!romaneio?.id) {
toast.error('Não é possível criar um novo romaneio diretamente como "Entregue".'); toast.error('Não é possível criar um novo romaneio diretamente como "Entregue".');
setFormData(prev => ({ ...prev, status: 'Em planejamento' })); setFormData(prev => ({ ...prev, status: 'Em planejamento' }));
return; return;
} }
setValidandoEntrega(true); // Se tentar salvar como Entregue sem ter passado pela confirmação, abrir modal
const resultado = await validarRomaneioParaEntrega(romaneio.id, formData.of_number || romaneio.of_number); setModalConfirmacaoAberto(true);
setValidandoEntrega(false); return;
if (!resultado.valido) {
setPendenciasEncontradas(resultado.pendencias || []);
setMensagemBloqueio(resultado.mensagem);
setModalPendenciasAberto(true);
toast.error('Não é possível definir como "Entregue": existem pendências na produção.');
return;
}
} }
console.log('💾 Salvando romaneio com dados:', formData); console.log('💾 Salvando romaneio com dados:', formData);
@@ -322,7 +302,6 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
<Select <Select
value={formData.status} value={formData.status}
onValueChange={handleStatusChange} onValueChange={handleStatusChange}
disabled={validandoEntrega}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -339,10 +318,10 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
</div> </div>
{/* Badge de Alerta */} {/* Badge de Alerta */}
<Alert className="border-orange-500 bg-orange-50 dark:bg-orange-950/20 dark:border-orange-400 mt-4"> <Alert className="border-blue-500 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-400 mt-4">
<AlertTriangle className="h-4 w-4 text-orange-600 dark:text-orange-400" /> <AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<AlertDescription className="text-orange-800 dark:text-orange-200 font-medium"> <AlertDescription className="text-blue-900 dark:text-blue-200 font-medium">
QUANDO O STATUS PASSAR PARA "ENTREGUE" AS PEÇAS SERÃO APONTADAS AUTOMATICAMENTE NO PROCESSO DE EXPEDIÇÃO (DESDE QUE TODOS OS PROCESSOS ANTERIORES ESTEJAM CONCLUÍDOS) AO PASSAR O STATUS PARA "ENTREGUE", VOCÊ ESCOLHE A DATA REAL DA ENTREGA E PODE AUTORIZAR O APONTAMENTO AUTOMÁTICO (INCLUSIVE EM CASCATA CASO HAJA PROCESSOS ANTERIORES PENDENTES).
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>
@@ -491,13 +470,8 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
</div> </div>
<div className="flex gap-2 pt-4"> <div className="flex gap-2 pt-4">
<Button type="submit" disabled={loading || validandoEntrega || !formData.of_number}> <Button type="submit" disabled={loading || !formData.of_number}>
{validandoEntrega ? ( {loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Validando processos...
</>
) : loading ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Salvando... Salvando...
@@ -515,19 +489,28 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Modal de bloqueio/alerta caso existam peças pendentes */} {/* Modal de confirmação inteligente com Data Real e Cascata */}
<PendenciasProducaoModal <ConfirmacaoEntregaRomaneioModal
isOpen={modalPendenciasAberto} isOpen={modalConfirmacaoAberto}
onClose={() => { onClose={() => {
setModalPendenciasAberto(false); setModalConfirmacaoAberto(false);
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
status: (romaneio?.status as any) || 'Em planejamento' status: (romaneio?.status as any) || 'Em planejamento'
})); }));
}} }}
pendencias={pendenciasEncontradas} romaneio={romaneio ? {
numeroRomaneio={romaneio?.numero_romaneio} id: romaneio.id,
mensagemSemItens={mensagemBloqueio} numero_romaneio: romaneio.numero_romaneio,
of_number: formData.of_number || romaneio.of_number,
data_prevista_entrega: formData.data_prevista_entrega || romaneio.data_prevista_entrega,
data_romaneio: formData.data_romaneio || romaneio.data_romaneio,
status: formData.status
} : null}
onSucesso={() => {
setModalConfirmacaoAberto(false);
onClose();
}}
/> />
</> </>
); );
+23 -2
View File
@@ -3,10 +3,11 @@ import React, { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Edit, Eye, Trash2, Printer } from 'lucide-react'; import { Edit, Eye, Trash2, Printer, Truck } from 'lucide-react';
import { RomaneioExpedicao } from '@/hooks/useRomaneios'; import { RomaneioExpedicao } from '@/hooks/useRomaneios';
import { formatBrazilianDateFromString } from '@/utils/dateTimeUtils'; import { formatBrazilianDateFromString } from '@/utils/dateTimeUtils';
import { RomaneioReportModal } from './RomaneioReportModal'; import { RomaneioReportModal } from './RomaneioReportModal';
import { ConfirmacaoEntregaRomaneioModal } from './ConfirmacaoEntregaRomaneioModal';
interface RomaneioTableProps { interface RomaneioTableProps {
@@ -28,6 +29,7 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
}) => { }) => {
const [selectedRomaneioForReport, setSelectedRomaneioForReport] = useState<RomaneioExpedicao | null>(null); const [selectedRomaneioForReport, setSelectedRomaneioForReport] = useState<RomaneioExpedicao | null>(null);
const [showReportModal, setShowReportModal] = useState(false); const [showReportModal, setShowReportModal] = useState(false);
const [romaneioParaConfirmacao, setRomaneioParaConfirmacao] = useState<RomaneioExpedicao | null>(null);
const handlePrintReport = (romaneio: RomaneioExpedicao) => { const handlePrintReport = (romaneio: RomaneioExpedicao) => {
setSelectedRomaneioForReport(romaneio); setSelectedRomaneioForReport(romaneio);
@@ -126,7 +128,16 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
{romaneio.peso_total_romaneio?.toFixed(2) || '0.00'} kg {romaneio.peso_total_romaneio?.toFixed(2) || '0.00'} kg
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setRomaneioParaConfirmacao(romaneio)}
title="Confirmar Entrega / Apontamentos"
className="h-8 w-8 p-0 text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/40"
>
<Truck className="h-4 w-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -175,6 +186,16 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
romaneio={selectedRomaneioForReport} romaneio={selectedRomaneioForReport}
/> />
)} )}
{/* Modal de Confirmação de Entrega com Data Real e Cascata */}
{romaneioParaConfirmacao && (
<ConfirmacaoEntregaRomaneioModal
isOpen={!!romaneioParaConfirmacao}
onClose={() => setRomaneioParaConfirmacao(null)}
romaneio={romaneioParaConfirmacao}
onSucesso={() => setRomaneioParaConfirmacao(null)}
/>
)}
</> </>
); );
}; };
@@ -0,0 +1,691 @@
import React, { useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
AlertTriangle,
CheckCircle2,
XCircle,
Search,
Printer,
ChevronDown,
ChevronRight,
Factory,
Layers,
ArrowRight,
ShieldAlert,
Sparkles,
Info,
} from 'lucide-react';
import { toast } from 'sonner';
import {
ItemAnaliseImportacao,
NivelRiscoImportacao,
ResultadoAnaliseImportacao,
} from '@/types/importDiff';
import { IntervencaoFabrilPrintModal } from './IntervencaoFabrilPrintModal';
import { executarImportacaoConciliada } from '@/utils/importExecutionService';
import { useAuth } from '@/hooks/useAuth';
interface ImportarPecasDiffModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
analise: ResultadoAnaliseImportacao | null;
componentesMap: Map<string, any[]>;
onConcluido: () => void;
ofDefault?: string;
}
type TabFiltro = 'todas' | 'divergencias' | 'apontadas' | 'criticas' | 'novas' | 'identicas';
export const ImportarPecasDiffModal: React.FC<ImportarPecasDiffModalProps> = ({
open,
onOpenChange,
analise,
componentesMap,
onConcluido,
ofDefault = '',
}) => {
const { user } = useAuth();
const [itens, setItens] = useState<ItemAnaliseImportacao[]>([]);
const [tabAtiva, setTabFiltro] = useState<TabFiltro>('todas');
const [busca, setBusca] = useState('');
const [linhasExpandidas, setLinhasExpandidas] = useState<Set<string>>(new Set());
const [showPrintModal, setShowPrintModal] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// Inicializar itens locais quando a análise for fornecida
React.useEffect(() => {
if (analise?.itens) {
setItens(analise.itens);
}
}, [analise]);
// Contadores dinâmicos baseados no estado atual dos itens
const counts = useMemo(() => {
return {
total: itens.length,
novas: itens.filter((i) => i.isNova).length,
divergencias: itens.filter((i) => i.temDivergencias).length,
apontadas: itens.filter((i) => i.apontamentoResumo.temApontamento).length,
criticas: itens.filter((i) => i.nivelRisco === 'critico').length,
identicas: itens.filter((i) => i.isIdentica).length,
sobreporTotal: itens.filter((i) => i.sobrepor).length,
atencaoEspecialTotal: itens.filter((i) => i.atencaoEspecial).length,
};
}, [itens]);
// Filtro dos itens exibidos
const itensFiltrados = useMemo(() => {
return itens.filter((item) => {
// Filtro de Busca
const matchBusca =
!busca ||
item.marca.toLowerCase().includes(busca.toLowerCase()) ||
item.of_number.toLowerCase().includes(busca.toLowerCase()) ||
item.descricao.toLowerCase().includes(busca.toLowerCase()) ||
(item.dadosImportados.perfil_principal &&
item.dadosImportados.perfil_principal.toLowerCase().includes(busca.toLowerCase()));
if (!matchBusca) return false;
// Filtro de Aba
if (tabAtiva === 'novas') return item.isNova;
if (tabAtiva === 'divergencias') return item.temDivergencias;
if (tabAtiva === 'apontadas') return item.apontamentoResumo.temApontamento;
if (tabAtiva === 'criticas') return item.nivelRisco === 'critico';
if (tabAtiva === 'identicas') return item.isIdentica;
return true;
});
}, [itens, busca, tabAtiva]);
// Toggle do checkbox Sobrepor por item
const handleToggleSobrepor = (key: string) => {
setItens((prev) =>
prev.map((item) => {
if (item.key === key) {
return { ...item, sobrepor: !item.sobrepor };
}
return item;
})
);
};
// Toggle do checkbox Atenção Especial por item
const handleToggleAtencaoEspecial = (key: string) => {
setItens((prev) =>
prev.map((item) => {
if (item.key === key) {
return { ...item, atencaoEspecial: !item.atencaoEspecial };
}
return item;
})
);
};
// Ações em massa
const handleMarcarTodasSobrepor = (valor: boolean) => {
const keysFiltradas = new Set(itensFiltrados.map((i) => i.key));
setItens((prev) =>
prev.map((item) => {
if (keysFiltradas.has(item.key)) {
return { ...item, sobrepor: valor };
}
return item;
})
);
};
const handleMarcarCriticasAtencaoEspecial = () => {
setItens((prev) =>
prev.map((item) => {
if (item.nivelRisco === 'critico' && item.sobrepor) {
return { ...item, atencaoEspecial: true };
}
return item;
})
);
toast.info('Todas as peças críticas marcadas para emissão de Ordem de Intervenção!');
};
const handleToggleExpandir = (key: string) => {
setLinhasExpandidas((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// Executar a importação conciliada
const handleConfirmarImportacao = async () => {
if (!user) {
toast.error('Usuário não autenticado');
return;
}
const sobreporCount = itens.filter((i) => i.sobrepor).length;
if (sobreporCount === 0) {
toast.warning('Nenhuma peça foi selecionada para importação ou sobreposição.');
return;
}
setIsProcessing(true);
try {
const resultado = await executarImportacaoConciliada({
itens,
componentesMap,
userId: user.id,
userNome: user.name || user.email || 'Operador',
userEmail: user.email || '',
motivoRevisao: 'Conciliação e atualização de lista técnica de peças',
});
if (resultado.sucesso) {
toast.success(resultado.mensagem);
// Se houver peças marcadas com atenção especial, perguntar ou abrir modal de impressão
if (resultado.totalComAtencaoEspecial > 0) {
toast.info(
`Atenção: ${resultado.totalComAtencaoEspecial} peça(s) requerem atenção do chão de fábrica! Abrindo Ordem de Intervenção...`
);
setShowPrintModal(true);
} else {
onOpenChange(false);
onConcluido();
}
} else {
toast.error(resultado.mensagem);
}
} catch (err: any) {
console.error('Erro na confirmação:', err);
toast.error(`Falha ao salvar dados: ${err?.message || 'Erro desconhecido'}`);
} finally {
setIsProcessing(false);
}
};
const itensParaImpressao = useMemo(() => {
return itens.filter((i) => i.atencaoEspecial);
}, [itens]);
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl h-[90vh] max-h-[92vh] flex flex-col p-0 overflow-hidden bg-background border-border">
{/* Cabeçalho */}
<DialogHeader className="p-4 border-b border-border bg-card flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Layers className="h-6 w-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold text-foreground flex items-center gap-2">
Detecção de Conflitos e Conciliação de Peças
<Badge variant="outline" className="text-xs font-semibold">
{ofDefault ? `OF ${ofDefault}` : 'Importação de Lista'}
</Badge>
</DialogTitle>
<p className="text-xs text-muted-foreground mt-0.5">
Foram identificadas peças já cadastradas. Analise as diferenças, selecione quais sobrepor e proteja os apontamentos fabris.
</p>
</div>
</div>
{/* Botão de emissão rápida da lista de atenção */}
{counts.atencaoEspecialTotal > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setShowPrintModal(true)}
className="bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 text-xs flex items-center gap-1.5 font-bold"
>
<Printer className="h-4 w-4 text-amber-600 dark:text-amber-400" />
Imprimir Lembretes Fabris ({counts.atencaoEspecialTotal})
</Button>
)}
</div>
{/* Cards de Métricas (KPIs) */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mt-4">
<div
onClick={() => setTabFiltro('criticas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'criticas'
? 'bg-red-500/15 border-red-500/50 dark:bg-red-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-red-600 dark:text-red-400 font-bold">
<span className="flex items-center gap-1">
<ShieldAlert className="h-3.5 w-3.5" /> Atenção Crítica
</span>
<Badge className="bg-red-600 text-white font-extrabold text-[11px] h-5 px-1.5">
{counts.criticas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Alteração em peças já iniciadas
</div>
</div>
<div
onClick={() => setTabFiltro('apontadas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'apontadas'
? 'bg-amber-500/15 border-amber-500/50 dark:bg-amber-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-amber-600 dark:text-amber-400 font-bold">
<span className="flex items-center gap-1">
<Factory className="h-3.5 w-3.5" /> Em Produção
</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.apontadas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Possuem apontamentos
</div>
</div>
<div
onClick={() => setTabFiltro('divergencias')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'divergencias'
? 'bg-blue-500/15 border-blue-500/50 dark:bg-blue-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-blue-600 dark:text-blue-400 font-bold">
<span>Com Divergência</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.divergencias}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Mudança em relação à base
</div>
</div>
<div
onClick={() => setTabFiltro('novas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'novas'
? 'bg-purple-500/15 border-purple-500/50 dark:bg-purple-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-purple-600 dark:text-purple-400 font-bold">
<span className="flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5" /> Peças Novas
</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.novas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Inexistentes no cadastro
</div>
</div>
<div
onClick={() => setTabFiltro('identicas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'identicas'
? 'bg-emerald-500/15 border-emerald-500/50 dark:bg-emerald-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-emerald-600 dark:text-emerald-400 font-bold">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5" /> 100% Idênticas
</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.identicas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Sem alterações necessárias
</div>
</div>
</div>
</DialogHeader>
{/* Barra de Filtros e Ferramentas */}
<div className="p-3 border-b border-border bg-muted/20 flex flex-wrap items-center justify-between gap-2 text-xs flex-shrink-0">
{/* Input de Busca */}
<div className="relative w-72">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Buscar por marca, perfil, fase..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 h-8 text-xs bg-background"
/>
</div>
{/* Botões de Ação Rápida */}
<div className="flex items-center gap-1.5 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => handleMarcarTodasSobrepor(true)}
className="h-7 text-[11px] px-2.5"
>
Sobrepor Todas Filtradas
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleMarcarTodasSobrepor(false)}
className="h-7 text-[11px] px-2.5"
>
Ignorar Todas (Manter Base)
</Button>
{counts.criticas > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleMarcarCriticasAtencaoEspecial}
className="h-7 text-[11px] px-2.5 bg-red-500/10 text-red-600 border-red-500/30 hover:bg-red-500/20 font-semibold"
>
<ShieldAlert className="h-3 w-3 mr-1" />
Marcar Críticas p/ Lembrete
</Button>
)}
</div>
</div>
{/* Tabela de Conciliação e Diff com Scroll Vertical e Cabeçalho Sticky */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-auto w-full">
<div className="p-4">
<table className="w-full text-left text-xs border-collapse">
<thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm shadow-sm">
<tr className="border-b border-border text-muted-foreground font-semibold">
<th className="p-2 w-10 text-center bg-muted/95">Sobrepor</th>
<th className="p-2 w-28 bg-muted/95">Peça / Fase</th>
<th className="p-2 w-44 bg-muted/95">Perfil / Bitola</th>
<th className="p-2 w-40 bg-muted/95">Status de Produção</th>
<th className="p-2 bg-muted/95">Divergências Detectadas (Diff)</th>
<th className="p-2 w-28 text-center bg-muted/95">Lembrete Fabril</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{itensFiltrados.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-muted-foreground">
Nenhuma peça encontrada com os filtros selecionados.
</td>
</tr>
) : (
itensFiltrados.map((item) => {
const isExpandida = linhasExpandidas.has(item.key);
const ultimoProc = item.apontamentoResumo.ultimoProcessoNome;
const totalApontado = item.apontamentoResumo.totalProduzido;
// Cor da linha de acordo com o risco
let linhaBg = 'hover:bg-muted/30';
if (item.nivelRisco === 'critico') {
linhaBg = 'bg-red-500/5 hover:bg-red-500/10 dark:bg-red-950/20';
} else if (item.isNova) {
linhaBg = 'bg-purple-500/5 hover:bg-purple-500/10 dark:bg-purple-950/20';
}
return (
<React.Fragment key={item.key}>
<tr className={`transition-colors ${linhaBg}`}>
{/* Checkbox de Sobreposição */}
<td className="p-2 text-center">
<Checkbox
checked={item.sobrepor}
onCheckedChange={() => handleToggleSobrepor(item.key)}
title={item.sobrepor ? 'Sobrepor no cadastro' : 'Ignorar e manter atual'}
/>
</td>
{/* Identificação da Peça */}
<td className="p-2">
<div className="flex items-center gap-1.5">
<span className="font-extrabold text-sm text-foreground">
{item.marca}
</span>
{item.nivelRisco === 'critico' && (
<Badge className="bg-red-600 text-white text-[9px] h-4 px-1 font-bold">
ALERTA
</Badge>
)}
{item.isNova && (
<Badge className="bg-purple-600 text-white text-[9px] h-4 px-1 font-bold">
NOVA
</Badge>
)}
</div>
<div className="text-[11px] text-muted-foreground">
OF {item.of_number} • Fase {item.etapa_fase}
</div>
</td>
{/* Perfil e Descrição */}
<td className="p-2">
<div className="font-semibold text-foreground">
{item.dadosImportados.perfil_principal || item.descricao || '-'}
</div>
<div className="text-[10px] text-muted-foreground">
{item.dadosImportados.comprimento
? `Comp: ${item.dadosImportados.comprimento} mm`
: 'Sem comp. ref.'}
</div>
</td>
{/* Status Fabril (Apontamentos) */}
<td className="p-2">
{item.apontamentoResumo.temApontamento ? (
<div className="space-y-0.5">
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/30">
<Factory className="h-3 w-3" />
{ultimoProc ? ultimoProc.toUpperCase() : 'PRODUÇÃO'}
</div>
<div className="text-[10px] text-muted-foreground">
{totalApontado} un apontadas
</div>
{item.apontamentoResumo.emRomaneio && (
<div className="text-[10px] font-bold text-red-600">
⚠️ Já expedida em Romaneio
</div>
)}
</div>
) : (
<span className="text-[11px] text-muted-foreground italic flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
Não iniciada (0%)
</span>
)}
</td>
{/* Coluna de Divergências (Diff) */}
<td className="p-2">
{item.isNova ? (
<span className="text-[11px] text-purple-600 dark:text-purple-400 font-medium">
Peça nova para cadastro
</span>
) : item.isIdentica ? (
<span className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5" /> Dados 100% idênticos à base
</span>
) : (
<div className="space-y-1">
<div className="flex flex-wrap gap-1.5">
{item.camposDivergentes.map((c) => (
<div
key={c.campo}
className={`px-2 py-0.5 rounded text-[11px] font-medium border flex items-center gap-1 ${
c.isCritico
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400'
}`}
>
<span className="font-bold">{c.label}:</span>
<span className="line-through opacity-75">{c.valorAntigo}</span>
<ArrowRight className="h-2.5 w-2.5" />
<span className="font-extrabold">{c.valorNovo}</span>
</div>
))}
</div>
{/* Resumo de componentes se houver */}
{item.componentesDivergentes.length > 0 && (
<button
type="button"
onClick={() => handleToggleExpandir(item.key)}
className="text-[11px] text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 font-semibold"
>
{isExpandida ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{item.componentesDivergentes.length} alteração(ões) em subcomponentes
</button>
)}
</div>
)}
</td>
{/* Checkbox de Lembrete Fabril (Atenção Especial) */}
<td className="p-2 text-center">
{item.apontamentoResumo.temApontamento || item.nivelRisco === 'critico' ? (
<div className="flex flex-col items-center justify-center">
<Checkbox
checked={item.atencaoEspecial}
onCheckedChange={() => handleToggleAtencaoEspecial(item.key)}
title="Incluir na Ordem de Intervenção Fabril Impressa"
/>
<span className="text-[9px] text-muted-foreground mt-0.5">
Lembrete
</span>
</div>
) : (
<span className="text-muted-foreground/40 text-[10px]">-</span>
)}
</td>
</tr>
{/* Linha Expansível para Detalhe de Componentes */}
{isExpandida && item.componentesDivergentes.length > 0 && (
<tr className="bg-muted/30">
<td colSpan={6} className="p-3 pl-12">
<div className="bg-background rounded-lg p-3 border border-border space-y-1.5">
<div className="font-bold text-xs text-foreground flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-blue-500" />
Detalhamento de Subcomponentes da Peça {item.marca}:
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
{item.componentesDivergentes.map((comp) => {
let badgeCor = 'bg-blue-500/10 text-blue-700 border-blue-300';
if (comp.tipo === 'adicionado') badgeCor = 'bg-emerald-500/10 text-emerald-700 border-emerald-300';
if (comp.tipo === 'removido') badgeCor = 'bg-red-500/10 text-red-700 border-red-300';
return (
<div
key={comp.marca_componente}
className={`p-2 rounded border flex items-start gap-2 ${badgeCor}`}
>
<Badge variant="outline" className="text-[10px] font-bold">
Pos. {comp.marca_componente}
</Badge>
<div className="text-[11px] space-y-0.5">
<div className="font-semibold">{comp.descricao || comp.perfil || 'Componente'}</div>
<div className="text-[10px] opacity-90">{comp.detalhe}</div>
</div>
</div>
);
})}
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Rodapé da Janela com Decisão e Ações */}
<DialogFooter className="p-3 border-t border-border bg-card flex flex-row items-center justify-between flex-shrink-0">
<div className="text-xs text-muted-foreground flex items-center gap-3">
<span>
<strong className="text-foreground">{counts.sobreporTotal}</strong> a sobrepor
</span>
<span>•</span>
<span>
<strong className="text-foreground">{counts.novas}</strong> novas
</span>
<span>•</span>
<span>
<strong className="text-foreground">{counts.total - counts.sobreporTotal}</strong> mantidas originais
</span>
{counts.atencaoEspecialTotal > 0 && (
<>
<span>•</span>
<Badge variant="outline" className="bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/30 text-[10px] font-bold">
{counts.atencaoEspecialTotal} na lista impressa
</Badge>
</>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isProcessing}
className="text-xs"
>
Cancelar
</Button>
<Button
size="sm"
onClick={handleConfirmarImportacao}
disabled={isProcessing || counts.sobreporTotal === 0}
className="bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-bold shadow-sm"
>
{isProcessing ? 'Gravando e Conciliando...' : `Confirmar Importação (${counts.sobreporTotal})`}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal de Impressão da Ordem de Intervenção */}
<IntervencaoFabrilPrintModal
open={showPrintModal}
onOpenChange={(openState) => {
setShowPrintModal(openState);
if (!openState) {
// Se fechou a janela de impressão após a importação, fecha o diff modal e conclui
onOpenChange(false);
onConcluido();
}
}}
itens={itensParaImpressao}
ofNumber={ofDefault || itens[0]?.of_number || ''}
usuarioNome={user?.name || user?.email || 'Operador'}
/>
</>
);
};
+45 -9
View File
@@ -9,6 +9,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { AlertTriangle, Check, X, Upload, FileSpreadsheet, Weight, AlertCircle, Edit3 } from 'lucide-react'; import { AlertTriangle, Check, X, Upload, FileSpreadsheet, Weight, AlertCircle, Edit3 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { parseCSV } from '@/utils/csvUtils'; import { parseCSV } from '@/utils/csvUtils';
import { analisarConflitosImportacao, consolidarPecasEComponentes } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
interface PecaCSVData { interface PecaCSVData {
of_number: string; of_number: string;
@@ -22,10 +25,13 @@ interface PecaCSVData {
material: string; material: string;
perfil_principal: string; perfil_principal: string;
tem_componentes: boolean; tem_componentes: boolean;
comprimento?: number | null;
comprimento_ref?: number | string;
marca_componente?: string; marca_componente?: string;
descricao_componente?: string; descricao_componente?: string;
perfil_componente?: string; perfil_componente?: string;
peso_unitario_componente?: number; peso_unitario_componente?: number;
comprimento_componente?: number | null;
quantidade_por_peca?: number; quantidade_por_peca?: number;
} }
@@ -62,6 +68,9 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<'upload' | 'preview'>('upload'); const [step, setStep] = useState<'upload' | 'preview'>('upload');
const [showErrors, setShowErrors] = useState(false); const [showErrors, setShowErrors] = useState(false);
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const requiredHeaders = [ const requiredHeaders = [
'of_number', 'etapa_fase', 'marca', 'descricao', 'quantidade', 'of_number', 'etapa_fase', 'marca', 'descricao', 'quantidade',
@@ -102,7 +111,7 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
}); });
// Validar e converter campos numéricos // Validar e converter campos numéricos
const numericFields = ['quantidade', 'peso_unitario', 'peso_total', 'peso_unitario_componente', 'quantidade_por_peca']; const numericFields = ['quantidade', 'peso_unitario', 'peso_total', 'peso_unitario_componente', 'quantidade_por_peca', 'comprimento', 'comprimento_ref', 'comprimento_componente'];
const convertedData: any = { ...rawRow }; const convertedData: any = { ...rawRow };
numericFields.forEach(field => { numericFields.forEach(field => {
@@ -171,10 +180,12 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
material: convertedData.material?.toString().trim() || '', material: convertedData.material?.toString().trim() || '',
perfil_principal: convertedData.perfil_principal?.toString().trim() || '', perfil_principal: convertedData.perfil_principal?.toString().trim() || '',
tem_componentes: convertedData.tem_componentes, tem_componentes: convertedData.tem_componentes,
comprimento: convertedData.comprimento ? Number(convertedData.comprimento) : (convertedData.comprimento_ref ? Number(convertedData.comprimento_ref) : null),
marca_componente: convertedData.marca_componente?.toString().trim() || '', marca_componente: convertedData.marca_componente?.toString().trim() || '',
descricao_componente: convertedData.descricao_componente?.toString().trim() || '', descricao_componente: convertedData.descricao_componente?.toString().trim() || '',
perfil_componente: convertedData.perfil_componente?.toString().trim() || '', perfil_componente: convertedData.perfil_componente?.toString().trim() || '',
peso_unitario_componente: convertedData.peso_unitario_componente || 0, peso_unitario_componente: convertedData.peso_unitario_componente || 0,
comprimento_componente: convertedData.comprimento_componente ? Number(convertedData.comprimento_componente) : null,
quantidade_por_peca: convertedData.quantidade_por_peca || 0 quantidade_por_peca: convertedData.quantidade_por_peca || 0
}; };
@@ -307,19 +318,29 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
}; };
const handleImport = async () => { const handleImport = async () => {
const validPecas = csvData.filter((_, index) => !duplicates[index]?.isDuplicate); if (csvData.length === 0) {
console.log('🚀 Iniciando importação de', validPecas.length, 'peças...');
if (validPecas.length === 0) {
toast.error('Nenhuma peça válida para importar'); toast.error('Nenhuma peça válida para importar');
return; return;
} }
setIsLoading(true); setIsLoading(true);
try { try {
await onImport(validPecas); // Analisar conflitos e status na base de dados
console.log('✅ Importação concluída com sucesso!'); toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(csvData);
const { componentesMap } = consolidarPecasEComponentes(csvData);
// Se houver peças já cadastradas na base
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsLoading(false);
return;
}
await onImport(csvData);
toast.success(`${csvData.length} novas peças importadas com sucesso!`);
handleClose(); handleClose();
} catch (error) { } catch (error) {
console.error('❌ Erro na importação:', error); console.error('❌ Erro na importação:', error);
@@ -352,7 +373,8 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
.reduce((sum, peca) => sum + peca.peso_total, 0); .reduce((sum, peca) => sum + peca.peso_total, 0);
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-6xl max-h-[80vh]"> <DialogContent className="max-w-6xl max-h-[80vh]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
@@ -601,5 +623,19 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Modal de Detecção de Conflitos e Conciliação */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={csvData[0]?.of_number || ''}
onConcluido={() => {
setShowDiffModal(false);
handleClose();
}}
/>
</>
); );
} }
+41 -4
View File
@@ -15,6 +15,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { FileSpreadsheet, Upload, Download, CheckCircle2, AlertCircle, Trash2, ArrowLeft, RefreshCw } from 'lucide-react'; import { FileSpreadsheet, Upload, Download, CheckCircle2, AlertCircle, Trash2, ArrowLeft, RefreshCw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { analisarConflitosImportacao, consolidarPecasEComponentes, extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
export interface PecaXLSData { export interface PecaXLSData {
of_number: string; of_number: string;
@@ -55,6 +58,9 @@ export function ImportarXLSModal({
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [step, setStep] = useState<'upload' | 'preview'>('upload'); const [step, setStep] = useState<'upload' | 'preview'>('upload');
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const resetState = () => { const resetState = () => {
@@ -465,8 +471,8 @@ export function ImportarXLSModal({
quantidade: p.quantidade, quantidade: p.quantidade,
peso_unitario: p.peso_unitario, peso_unitario: p.peso_unitario,
peso_total: p.peso_total, peso_total: p.peso_total,
comprimento: p.comprimento !== undefined && p.comprimento !== null ? Number(p.comprimento) : (p.comprimento_ref ? Number(p.comprimento_ref) : null), comprimento: extrairComprimentoSeguro(p),
comprimento_ref: p.comprimento_ref ? Number(p.comprimento_ref) : (p.comprimento ? Number(p.comprimento) : null), comprimento_ref: extrairComprimentoSeguro(p),
tratamento_superficial: p.tratamento_superficial || 'pintura', tratamento_superficial: p.tratamento_superficial || 'pintura',
material: p.material, material: p.material,
perfil_principal: p.perfil_principal, perfil_principal: p.perfil_principal,
@@ -475,11 +481,27 @@ export function ImportarXLSModal({
descricao_componente: p.descricao_componente, descricao_componente: p.descricao_componente,
perfil_componente: p.perfil_componente, perfil_componente: p.perfil_componente,
peso_unitario_componente: p.peso_unitario_componente, peso_unitario_componente: p.peso_unitario_componente,
comprimento_componente: extrairComprimentoComponenteSeguro(p),
quantidade_por_peca: p.quantidade_por_peca, quantidade_por_peca: p.quantidade_por_peca,
})); }));
// Analisar conflitos com a base de dados
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(pecasPayload);
const { componentesMap } = consolidarPecasEComponentes(pecasPayload);
// Se houver qualquer peça já existente na base (com divergência, idêntica ou com apontamento fabril)
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsImporting(false);
return;
}
// Se todas forem 100% novas
await onImport(pecasPayload); await onImport(pecasPayload);
toast.success(`${pecasProcessadas.length} registros importados com sucesso para a OF!`); toast.success(`${pecasProcessadas.length} novas peças cadastradas com sucesso para a OF!`);
handleModalOpenChange(false); handleModalOpenChange(false);
} catch (err: unknown) { } catch (err: unknown) {
console.error('Erro ao importar peças do Excel:', err); console.error('Erro ao importar peças do Excel:', err);
@@ -544,7 +566,8 @@ export function ImportarXLSModal({
const totalQuantidade = pecasProcessadas.reduce((acc, p) => acc + (p.quantidade || 0), 0); const totalQuantidade = pecasProcessadas.reduce((acc, p) => acc + (p.quantidade || 0), 0);
return ( return (
<Dialog open={open} onOpenChange={handleModalOpenChange}> <>
<Dialog open={open} onOpenChange={handleModalOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-6 bg-card text-card-foreground border-border"> <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-6 bg-card text-card-foreground border-border">
<DialogHeader className="pb-3 border-b border-border"> <DialogHeader className="pb-3 border-b border-border">
<DialogTitle className="flex items-center justify-between"> <DialogTitle className="flex items-center justify-between">
@@ -767,5 +790,19 @@ export function ImportarXLSModal({
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Modal Comparador e Conciliador de Peças Repetidas */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={ofDefault}
onConcluido={() => {
setShowDiffModal(false);
handleModalOpenChange(false);
}}
/>
</>
); );
} }
@@ -0,0 +1,286 @@
import React, { useRef } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Printer, AlertTriangle, CheckCircle2, Copy, FileText, Factory } from 'lucide-react';
import { toast } from 'sonner';
import { ItemAnaliseImportacao } from '@/types/importDiff';
interface IntervencaoFabrilPrintModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
itens: ItemAnaliseImportacao[];
ofNumber?: string;
usuarioNome?: string;
}
export const IntervencaoFabrilPrintModal: React.FC<IntervencaoFabrilPrintModalProps> = ({
open,
onOpenChange,
itens,
ofNumber = '',
usuarioNome = 'Operador PCP',
}) => {
const printRef = useRef<HTMLDivElement>(null);
const handlePrint = () => {
window.print();
};
const handleCopyText = () => {
if (itens.length === 0) return;
const linhas = itens.map((item, idx) => {
const diffs = item.camposDivergentes.map((c) => `${c.label}: ${c.valorAntigo} ➔ ${c.valorNovo}`).join('; ');
const comps = item.componentesDivergentes.map((c) => c.detalhe).join('; ');
const ultimoProc = item.apontamentoResumo.ultimoProcessoNome || 'Não iniciado';
return `${idx + 1}. [${item.of_number}] Marca: ${item.marca} (Fase ${item.etapa_fase})
- Status Fabril: ${ultimoProc} (${item.apontamentoResumo.totalProduzido} un apontadas)
- Divergências: ${diffs || 'Nenhuma alteração de campo'} ${comps ? '| Subcomponentes: ' + comps : ''}
- Ação Recomendada: ${item.acaoRecomendadaFabril || item.motivoAlerta}
`;
});
const textoFinal = `ORDEM DE INTERVENÇÃO FABRIL - TRACKSTEEL
Data: ${new Date().toLocaleString('pt-BR')} | Responsável: ${usuarioNome}
OFs: ${ofNumber || 'Múltiplas'} | Total de Peças em Alerta: ${itens.length}
${linhas.join('\n')}
Aviso: Interromper avanço fabril das peças listadas até a conferência física.`;
navigator.clipboard.writeText(textoFinal);
toast.success('Lista de lembretes copiada para a área de transferência!');
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-0 overflow-hidden bg-background border-border">
{/* Cabeçalho da Janela */}
<DialogHeader className="p-4 border-b border-border bg-muted/40 flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-lg font-bold text-foreground flex items-center gap-2">
Ordem de Intervenção Fabril & Lista de Lembretes
<Badge variant="outline" className="bg-red-500/10 text-red-600 border-red-500/30 text-xs font-bold">
{itens.length} peça(s) com atenção especial
</Badge>
</DialogTitle>
<p className="text-xs text-muted-foreground">
Documento de alerta para o chão de fábrica sobre peças alteradas que já possuem apontamentos de produção.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCopyText}
className="text-xs flex items-center gap-1.5"
>
<Copy className="h-3.5 w-3.5" />
Copiar Texto
</Button>
<Button
size="sm"
onClick={handlePrint}
className="bg-blue-600 hover:bg-blue-700 text-white text-xs flex items-center gap-1.5 shadow-sm"
>
<Printer className="h-3.5 w-3.5" />
Imprimir (A4 / Ficha)
</Button>
</div>
</DialogHeader>
{/* Conteúdo Imprimível */}
<ScrollArea className="flex-1 p-6">
<div
ref={printRef}
className="print-container bg-white text-slate-900 p-6 rounded-lg border border-slate-200 shadow-sm print:p-0 print:border-none print:shadow-none font-sans"
>
{/* Cabeçalho Oficial do Relatório */}
<div className="border-b-2 border-slate-900 pb-4 mb-4 flex justify-between items-start">
<div>
<div className="flex items-center gap-2">
<Factory className="h-6 w-6 text-slate-900" />
<span className="font-black text-xl tracking-tight text-slate-900">TRACKSTEEL</span>
<span className="text-xs uppercase bg-slate-100 font-bold px-2 py-0.5 rounded text-slate-700 border border-slate-300">
Industrial PCP
</span>
</div>
<h1 className="text-base font-extrabold uppercase mt-1 text-slate-900">
ORDEM DE INTERVENÇÃO FABRIL & ALERTA DE REVISÃO
</h1>
<p className="text-xs text-slate-600">
Atenção especial por atualização de engenharia em peças com processo fabril em andamento
</p>
</div>
<div className="text-right text-xs space-y-0.5 text-slate-700">
<p><span className="font-bold">OF:</span> {ofNumber || 'Geral / Múltiplas'}</p>
<p><span className="font-bold">Data/Hora:</span> {new Date().toLocaleString('pt-BR')}</p>
<p><span className="font-bold">Responsável:</span> {usuarioNome}</p>
<p><span className="font-bold text-red-600">Peças Afetadas:</span> {itens.length}</p>
</div>
</div>
{/* Aviso de Destaque */}
<div className="bg-amber-50 border-l-4 border-amber-500 p-3 mb-4 rounded text-xs text-amber-900 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 shrink-0 mt-0.5" />
<div>
<span className="font-bold">COMUNICADO URGENTE AOS LÍDERES DE PRODUÇÃO:</span> As peças abaixo sofreram alterações de projeto (dimensional, peso, componentes ou quantidade) mas já possuem avanço de fabricação registrado. Interrompa o avanço físico destas peças até a conferência técnica recomendada.
</div>
</div>
{/* Tabela de Ações e Lembretes */}
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-100 border-b-2 border-slate-300 text-slate-800 font-bold uppercase">
<th className="py-2 px-2.5 w-12 text-center">Item</th>
<th className="py-2 px-2.5 w-28">OF / Marca</th>
<th className="py-2 px-2.5 w-40">Perfil / Descrição</th>
<th className="py-2 px-2.5 w-36">Status no Chão de Fábrica</th>
<th className="py-2 px-2.5">O Que Mudou (Engenharia)</th>
<th className="py-2 px-2.5 w-52">Ação Recomendada</th>
<th className="py-2 px-2.5 w-28 text-center">Visto do Líder</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{itens.map((item, index) => {
const ultimoProc = item.apontamentoResumo.ultimoProcessoNome || 'Não iniciado';
const totalApontado = item.apontamentoResumo.totalProduzido;
return (
<tr key={item.key} className={index % 2 === 1 ? 'bg-slate-50/50' : 'bg-white'}>
<td className="py-2 px-2.5 text-center font-bold text-slate-500">
{index + 1}
</td>
<td className="py-2 px-2.5">
<div className="font-extrabold text-slate-900 text-sm">{item.marca}</div>
<div className="text-[10px] text-slate-500">
{item.of_number} • Fase {item.etapa_fase}
</div>
</td>
<td className="py-2 px-2.5">
<div className="font-semibold text-slate-800">
{item.dadosImportados.perfil_principal || item.descricao || '-'}
</div>
{item.dadosImportados.comprimento && (
<div className="text-[10px] text-slate-500">
Comp: {item.dadosImportados.comprimento} mm
</div>
)}
</td>
<td className="py-2 px-2.5">
<div className="inline-block px-2 py-0.5 rounded text-[11px] font-bold bg-amber-100 text-amber-900 border border-amber-300">
{ultimoProc.toUpperCase()}
</div>
<div className="text-[10px] text-slate-600 mt-0.5">
{totalApontado} de {item.dadosExistentes?.quantidade || item.dadosImportados.quantidade} un apontadas
</div>
{item.apontamentoResumo.emRomaneio && (
<div className="text-[10px] font-bold text-red-600">
⚠️ EM ROMANEIO
</div>
)}
</td>
<td className="py-2 px-2.5 space-y-1">
{item.camposDivergentes.map((c) => (
<div key={c.campo} className="text-[11px]">
<span className="font-bold text-slate-700">{c.label}:</span>{' '}
<span className="line-through text-red-600">{c.valorAntigo}</span>{' '}
➔ <span className="font-bold text-emerald-700">{c.valorNovo}</span>
</div>
))}
{item.componentesDivergentes.map((comp) => (
<div key={comp.marca_componente} className="text-[10px] text-blue-700 font-medium">
• {comp.detalhe}
</div>
))}
</td>
<td className="py-2 px-2.5">
<div className="text-[11px] font-semibold text-slate-900 bg-red-50 p-1.5 rounded border border-red-200">
{item.acaoRecomendadaFabril || item.motivoAlerta || 'Verificar peça na fábrica.'}
</div>
</td>
<td className="py-2 px-2.5 text-center">
<div className="h-10 border-b border-dashed border-slate-400 mt-2"></div>
<div className="text-[9px] text-slate-400 mt-0.5">Assinatura / Data</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Rodapé de Assinaturas */}
<div className="mt-8 pt-4 border-t border-slate-300 grid grid-cols-3 gap-6 text-center text-xs text-slate-600">
<div>
<div className="h-10 border-b border-slate-400 mb-1"></div>
<div className="font-bold text-slate-800">Engenharia / Projetista</div>
<div className="text-[10px]">Liberação de Revisão</div>
</div>
<div>
<div className="h-10 border-b border-slate-400 mb-1"></div>
<div className="font-bold text-slate-800">PCP / Planejamento</div>
<div className="text-[10px]">Ajuste de Fila & Diário</div>
</div>
<div>
<div className="h-10 border-b border-slate-400 mb-1"></div>
<div className="font-bold text-slate-800">Líder de Fabricação</div>
<div className="text-[10px]">Ciente & Intervenção Física</div>
</div>
</div>
</div>
</ScrollArea>
{/* Rodapé da Janela */}
<DialogFooter className="p-3 border-t border-border bg-muted/20 flex items-center justify-between">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<FileText className="h-4 w-4" />
Esta folha pode ser impressa em qualquer impressora A4 ou salva em PDF pelo navegador.
</div>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
Fechar Janela
</Button>
</DialogFooter>
{/* Estilo Global de Impressão (@media print) */}
<style dangerouslySetInnerHTML={{
__html: `
@media print {
body * {
visibility: hidden !important;
}
.print-container, .print-container * {
visibility: visible !important;
}
.print-container {
position: absolute !important;
left: 0 !important;
top: 0 !important;
width: 100% !important;
margin: 0 !important;
padding: 15mm !important;
background: white !important;
color: black !important;
}
}
`
}} />
</DialogContent>
</Dialog>
);
};
+28 -1
View File
@@ -2,7 +2,8 @@
import React from 'react'; import React from 'react';
import { Table, TableBody, TableHeader } from '@/components/ui/table'; import { Table, TableBody, TableHeader } from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Package } from 'lucide-react'; import { Package, FileText, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Peca } from '@/hooks/usePecas'; import { Peca } from '@/hooks/usePecas';
import { usePecasTable } from '@/hooks/usePecasTable'; import { usePecasTable } from '@/hooks/usePecasTable';
import { TableFilters } from './table/TableFilters'; import { TableFilters } from './table/TableFilters';
@@ -22,6 +23,9 @@ interface PecasTableProps {
onEditPeca?: (peca: Peca) => void; onEditPeca?: (peca: Peca) => void;
onDeleteLastImport?: () => void; onDeleteLastImport?: () => void;
onBatchUpdatePecas?: (pecaIds: string[], updates: any) => Promise<void>; onBatchUpdatePecas?: (pecaIds: string[], updates: any) => Promise<void>;
onOpenRevisaoPeca?: (peca: Peca) => void;
onOpenPainelRevisoes?: () => void;
totalRevisoesOF?: number;
hasRecentImport: boolean; hasRecentImport: boolean;
canEdit?: boolean; canEdit?: boolean;
canDelete?: boolean; canDelete?: boolean;
@@ -36,6 +40,9 @@ export function PecasTable({
onEditPeca, onEditPeca,
onDeleteLastImport, onDeleteLastImport,
onBatchUpdatePecas, onBatchUpdatePecas,
onOpenRevisaoPeca,
onOpenPainelRevisoes,
totalRevisoesOF,
hasRecentImport, hasRecentImport,
canEdit = true, canEdit = true,
canDelete = true, canDelete = true,
@@ -169,6 +176,25 @@ export function PecasTable({
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onOpenPainelRevisoes && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onOpenPainelRevisoes}
className="h-8 border-red-500/50 bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 hover:text-red-700 dark:hover:text-red-300 font-bold flex items-center gap-1.5 transition-all shadow-sm"
title="Abrir Registro e Gestão de Revisões da OF"
>
<FileText className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
<span>REG.REVISOES</span>
{totalRevisoesOF !== undefined && totalRevisoesOF > 0 && (
<span className="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-red-600 dark:bg-red-500 text-white font-black">
{totalRevisoesOF}
</span>
)}
</Button>
)}
{canEdit && onBatchUpdatePecas && ( {canEdit && onBatchUpdatePecas && (
<BatchEditButton <BatchEditButton
selectedPecas={selectedPecas} selectedPecas={selectedPecas}
@@ -209,6 +235,7 @@ export function PecasTable({
onEdit={() => handleEditPeca(peca)} onEdit={() => handleEditPeca(peca)}
onDelete={() => handleDeletePeca(peca.id)} onDelete={() => handleDeletePeca(peca.id)}
onOpenComponentPopup={() => onOpenComponentPopup(peca.id)} onOpenComponentPopup={() => onOpenComponentPopup(peca.id)}
onOpenRevisao={() => onOpenRevisaoPeca?.(peca)}
/> />
))} ))}
</TableBody> </TableBody>
@@ -0,0 +1,473 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import {
FileText,
Search,
Printer,
ExternalLink,
Filter,
RefreshCw
} from 'lucide-react';
import { useRevisoes, Revisao } from '@/hooks/useRevisoes';
import { Peca } from '@/hooks/usePecas';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface PainelRevisoesOFModalProps {
isOpen: boolean;
onClose: () => void;
ofNumber: string;
pecas: Peca[];
onOpenRevisaoPeca: (peca: Peca) => void;
}
export function PainelRevisoesOFModal({
isOpen,
onClose,
ofNumber,
pecas,
onOpenRevisaoPeca
}: PainelRevisoesOFModalProps) {
const { revisoes, loading, carregarRevisoesOF } = useRevisoes(ofNumber);
const [busca, setBusca] = useState('');
const [filtroTipo, setFiltroTipo] = useState<'todos' | 'critica' | 'simples'>('todos');
const [visualizandoRelatorio, setVisualizandoRelatorio] = useState(false);
useEffect(() => {
if (isOpen && ofNumber) {
carregarRevisoesOF(ofNumber);
setVisualizandoRelatorio(false);
}
}, [isOpen, ofNumber, carregarRevisoesOF]);
// Estatísticas
const stats = useMemo(() => {
const total = revisoes.length;
const criticas = revisoes.filter((r) => r.tipo_revisao === 'critica').length;
const simples = revisoes.filter((r) => r.tipo_revisao === 'simples').length;
const pecasUnicas = new Set(revisoes.map((r) => r.peca_id)).size;
return { total, criticas, simples, pecasUnicas };
}, [revisoes]);
// Filtro
const revisoesFiltradas = useMemo(() => {
return revisoes.filter((rev) => {
// Tipo
if (filtroTipo !== 'todos' && rev.tipo_revisao !== filtroTipo) {
return false;
}
// Busca texto
if (busca.trim()) {
const termo = busca.toLowerCase();
const marca = (rev.peca?.marca || '').toLowerCase();
const fase = (rev.peca?.etapa_fase || '').toLowerCase();
const motivo = (rev.descricao_motivo || '').toLowerCase();
const numero = (rev.numero_revisao || '').toLowerCase();
const autor = (rev.usuario_nome || '').toLowerCase();
return (
marca.includes(termo) ||
fase.includes(termo) ||
motivo.includes(termo) ||
numero.includes(termo) ||
autor.includes(termo)
);
}
return true;
});
}, [revisoes, filtroTipo, busca]);
const handleImprimirRelatorio = () => {
window.print();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto bg-background text-foreground border-border p-6 shadow-xl">
<DialogHeader className="border-b border-border pb-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-red-500/10 border border-red-500/30 flex items-center justify-center text-red-600 dark:text-red-400">
<FileText className="w-5 h-5" />
</div>
<div>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<span>Registro Geral de Revisões da OF</span>
<Badge variant="outline" className="font-mono text-amber-600 dark:text-amber-400 border-amber-400/40 bg-amber-500/10 text-sm">
{ofNumber || 'Geral'}
</Badge>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs mt-0.5">
Rastreabilidade técnica, histórico de modificações simples e críticas nas peças desta Ordem de Fabricação.
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setVisualizandoRelatorio(!visualizandoRelatorio)}
className="border-border text-foreground hover:bg-muted flex items-center gap-1.5 h-8 text-xs"
>
<Printer className="w-3.5 h-3.5 text-blue-500" />
{visualizandoRelatorio ? 'Voltar para Tabela' : 'Gerar Relatório'}
</Button>
</div>
</div>
</DialogHeader>
{/* MODO RELATÓRIO IMPRESSÃO */}
{visualizandoRelatorio ? (
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between bg-muted p-3 rounded-lg border border-border">
<span className="text-xs text-muted-foreground">
Pré-visualização do Relatório Técnico para impressão ou exportação.
</span>
<Button
size="sm"
onClick={handleImprimirRelatorio}
className="bg-blue-600 hover:bg-blue-500 text-white text-xs h-7 flex items-center gap-1 shadow-sm"
>
<Printer className="w-3.5 h-3.5" />
Imprimir / Salvar PDF
</Button>
</div>
{/* Documento A4 Formato Relatório */}
<div className="p-6 bg-white text-slate-900 rounded-lg shadow-lg text-xs space-y-4 font-sans border border-slate-200">
{/* Header do Relatório */}
<div className="border-b-2 border-slate-800 pb-3 flex justify-between items-start">
<div>
<h2 className="text-lg font-black tracking-wider text-slate-900">TRACKSTEEL INDUSTRIAL</h2>
<p className="text-[11px] text-slate-600">Sistema Integrado de Gestão & Rastreabilidade de Estruturas</p>
<p className="text-[11px] font-bold text-slate-800 mt-1">RELATÓRIO TÉCNICO DE GESTÃO DE REVISÕES</p>
</div>
<div className="text-right text-[11px] text-slate-700">
<p><strong>OF:</strong> {ofNumber}</p>
<p><strong>Emissão:</strong> {format(new Date(), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}</p>
<p><strong>Total de Revisões:</strong> {stats.total}</p>
</div>
</div>
{/* Tabela do Relatório */}
<table className="w-full border-collapse border border-slate-300 text-[10px]">
<thead>
<tr className="bg-slate-100 text-slate-800">
<th className="border border-slate-300 p-1.5 text-left">Data/Hora</th>
<th className="border border-slate-300 p-1.5 text-left">Peça</th>
<th className="border border-slate-300 p-1.5 text-center">Fase</th>
<th className="border border-slate-300 p-1.5 text-center">Revisão</th>
<th className="border border-slate-300 p-1.5 text-center">Tipo</th>
<th className="border border-slate-300 p-1.5 text-left">Motivo / Descrição</th>
<th className="border border-slate-300 p-1.5 text-left">Campos Alterados</th>
<th className="border border-slate-300 p-1.5 text-left">Responsável</th>
</tr>
</thead>
<tbody>
{revisoes.length === 0 ? (
<tr>
<td colSpan={8} className="p-4 text-center text-slate-500">
Nenhum registro de revisão para esta OF.
</td>
</tr>
) : (
revisoes.map((r) => (
<tr key={r.id} className="hover:bg-slate-50 border-b border-slate-200">
<td className="border border-slate-300 p-1.5 whitespace-nowrap">
{r.data_revisao ? format(new Date(r.data_revisao), 'dd/MM/yy HH:mm') : '-'}
</td>
<td className="border border-slate-300 p-1.5 font-bold">
{r.peca?.marca || '-'}
</td>
<td className="border border-slate-300 p-1.5 text-center">
{r.peca?.etapa_fase || '-'}
</td>
<td className="border border-slate-300 p-1.5 text-center font-bold">
{r.numero_revisao}
</td>
<td className="border border-slate-300 p-1.5 text-center font-semibold">
<span className={r.tipo_revisao === 'critica' ? 'text-red-600 font-bold' : 'text-blue-600 font-bold'}>
{r.tipo_revisao === 'critica' ? 'CRÍTICA' : 'SIMPLES'}
</span>
</td>
<td className="border border-slate-300 p-1.5">
{r.descricao_motivo}
{r.observacoes && <span className="block text-slate-500 italic mt-0.5">Obs: {r.observacoes}</span>}
</td>
<td className="border border-slate-300 p-1.5 font-mono text-[9px]">
{r.campos_alterados?.join(', ') || '-'}
</td>
<td className="border border-slate-300 p-1.5">
{r.usuario_nome || r.usuario_email || 'Sistema'}
</td>
</tr>
))
)}
</tbody>
</table>
{/* Rodapé e Assinaturas */}
<div className="pt-8 border-t border-slate-300 grid grid-cols-2 gap-8 text-center text-[10px] text-slate-600">
<div>
<div className="border-t border-slate-400 w-48 mx-auto mb-1"></div>
<p className="font-bold">Engenharia / PCP</p>
<p>Responsável pelo Lançamento</p>
</div>
<div>
<div className="border-t border-slate-400 w-48 mx-auto mb-1"></div>
<p className="font-bold">Supervisão de Produção</p>
<p>Aceite de Modificação</p>
</div>
</div>
</div>
</div>
) : (
/* MODO PAINEL / TABELA */
<div className="mt-4 space-y-4">
{/* CARDS DE MÉTRICAS */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 rounded-lg bg-card border border-border shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground block">
Total de Revisões
</span>
<span className="text-2xl font-black text-foreground mt-1 block">
{stats.total}
</span>
<span className="text-[10px] text-muted-foreground">histórico completo</span>
</div>
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-red-600 dark:text-red-400 block">
Revisões Críticas
</span>
<span className="text-2xl font-black text-red-700 dark:text-red-300 mt-1 block">
{stats.criticas}
</span>
<span className="text-[10px] text-red-600/80 dark:text-red-400/80">alteraram marca, qtd, peso</span>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30 shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 block">
Revisões Simples
</span>
<span className="text-2xl font-black text-blue-700 dark:text-blue-300 mt-1 block">
{stats.simples}
</span>
<span className="text-[10px] text-blue-600/80 dark:text-blue-400/80">dados informativos</span>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 block">
Peças Afetadas
</span>
<span className="text-2xl font-black text-amber-700 dark:text-amber-300 mt-1 block">
{stats.pecasUnicas}
</span>
<span className="text-[10px] text-amber-600/80 dark:text-amber-400/80">peças distintas</span>
</div>
</div>
{/* FILTROS E BUSCA */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2 flex-1 min-w-[240px]">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={busca}
onChange={(e) => setBusca(e.target.value)}
placeholder="Filtrar por peça, fase, motivo ou autor..."
className="pl-8 bg-background border-input text-xs h-8 text-foreground"
/>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs">
<Filter className="w-3.5 h-3.5 text-muted-foreground" />
<button
type="button"
onClick={() => setFiltroTipo('todos')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
filtroTipo === 'todos' ? 'bg-primary text-primary-foreground font-bold shadow-sm' : 'text-muted-foreground hover:bg-muted'
}`}
>
Todas ({stats.total})
</button>
<button
type="button"
onClick={() => setFiltroTipo('critica')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
filtroTipo === 'critica'
? 'bg-red-600 text-white font-bold shadow-sm'
: 'text-red-600 dark:text-red-400 hover:bg-red-500/10'
}`}
>
Críticas ({stats.criticas})
</button>
<button
type="button"
onClick={() => setFiltroTipo('simples')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
filtroTipo === 'simples'
? 'bg-blue-600 text-white font-bold shadow-sm'
: 'text-blue-600 dark:text-blue-400 hover:bg-blue-500/10'
}`}
>
Simples ({stats.simples})
</button>
<Button
variant="ghost"
size="sm"
onClick={() => carregarRevisoesOF(ofNumber)}
disabled={loading}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
title="Atualizar lista"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* TABELA DE REVISÕES */}
<div className="rounded-md border border-border bg-card overflow-hidden shadow-sm">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="border-border hover:bg-muted/50">
<TableHead className="text-muted-foreground text-xs w-28">Data</TableHead>
<TableHead className="text-muted-foreground text-xs w-20">Peça</TableHead>
<TableHead className="text-muted-foreground text-xs w-16 text-center">Fase</TableHead>
<TableHead className="text-muted-foreground text-xs w-20 text-center">Revisão</TableHead>
<TableHead className="text-muted-foreground text-xs w-20 text-center">Tipo</TableHead>
<TableHead className="text-muted-foreground text-xs">Motivo da Revisão</TableHead>
<TableHead className="text-muted-foreground text-xs">Campos Alterados</TableHead>
<TableHead className="text-muted-foreground text-xs w-28">Autor</TableHead>
<TableHead className="text-muted-foreground text-xs w-16 text-right">Ação</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground text-xs">
Carregando revisões da OF...
</TableCell>
</TableRow>
) : revisoesFiltradas.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground text-xs">
{revisoes.length === 0
? 'Nenhuma revisão cadastrada nesta OF.'
: 'Nenhuma revisão encontrada para os filtros selecionados.'}
</TableCell>
</TableRow>
) : (
revisoesFiltradas.map((r) => {
const pecaCorrespondente = pecas.find((p) => p.id === r.peca_id);
return (
<TableRow key={r.id} className="border-border hover:bg-muted/40 text-xs">
<TableCell className="font-mono text-muted-foreground whitespace-nowrap">
{r.data_revisao ? format(new Date(r.data_revisao), 'dd/MM/yy HH:mm') : '-'}
</TableCell>
<TableCell className="font-mono font-bold text-foreground">
{r.peca?.marca || '-'}
</TableCell>
<TableCell className="text-center text-foreground font-mono">
{r.peca?.etapa_fase || '-'}
</TableCell>
<TableCell className="text-center">
<Badge
variant="outline"
className="font-mono text-[11px] font-bold bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-400/40"
>
{r.numero_revisao}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge
className={`text-[10px] font-bold ${
r.tipo_revisao === 'critica'
? 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/30'
}`}
>
{r.tipo_revisao === 'critica' ? 'Crítica' : 'Simples'}
</Badge>
</TableCell>
<TableCell className="text-foreground">
<span className="font-medium block">{r.descricao_motivo}</span>
{r.observacoes && (
<span className="text-[10px] text-muted-foreground italic block mt-0.5">
{r.observacoes}
</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{r.campos_alterados?.map((c) => (
<span
key={c}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-foreground border border-border"
>
{c}
</span>
))}
</div>
</TableCell>
<TableCell className="text-muted-foreground truncate max-w-[120px]">
{r.usuario_nome || r.usuario_email || 'Sistema'}
</TableCell>
<TableCell className="text-right">
{pecaCorrespondente ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
onClose();
onOpenRevisaoPeca(pecaCorrespondente);
}}
title="Abrir CRUD / Histórico da Peça"
className="h-6 w-6 p-0 text-amber-600 dark:text-amber-400 hover:bg-amber-500/10"
>
<ExternalLink className="w-3.5 h-3.5" />
</Button>
) : (
<span className="text-muted-foreground text-[10px]">-</span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,619 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
AlertTriangle,
History,
ArrowRight,
TrendingUp,
TrendingDown,
Save,
Clock,
User,
Info,
ShieldAlert,
Edit3
} from 'lucide-react';
import { Peca } from '@/hooks/usePecas';
import { useRevisoes, Revisao, TipoRevisao } from '@/hooks/useRevisoes';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface RevisaoPecaModalProps {
isOpen: boolean;
onClose: () => void;
peca: Peca | null;
onPecaAtualizada?: () => void;
}
export function RevisaoPecaModal({
isOpen,
onClose,
peca,
onPecaAtualizada
}: RevisaoPecaModalProps) {
const { carregarRevisoesPeca, detectarTipoRevisao, criarRevisao, loading } = useRevisoes();
const [activeTab, setActiveTab] = useState<'nova' | 'historico'>('nova');
const [historico, setHistorico] = useState<Revisao[]>([]);
const [loadingHistorico, setLoadingHistorico] = useState(false);
// Campos editáveis do formulário
const [formData, setFormData] = useState({
marca: '',
quantidade: 0,
peso_unitario: 0,
peso_total: 0,
descricao: '',
etapa_fase: '',
material: '',
perfil_principal: '',
tratamento_superficial: '',
comprimento: 0
});
// Campos de controle da revisão
const [numeroRevisao, setNumeroRevisao] = useState('Rev 01');
const [motivo, setMotivo] = useState('');
const [observacoes, setObservacoes] = useState('');
const [tipoManual, setTipoManual] = useState<TipoRevisao | null>(null);
// Inicializa os dados quando o modal abre
useEffect(() => {
if (peca && isOpen) {
setFormData({
marca: peca.marca || '',
quantidade: Number(peca.quantidade) || 0,
peso_unitario: Number(peca.peso_unitario) || 0,
peso_total: Number(peca.peso_total) || 0,
descricao: peca.descricao || '',
etapa_fase: peca.etapa_fase || '',
material: peca.material || '',
perfil_principal: peca.perfil_principal || '',
tratamento_superficial: peca.tratamento_superficial || '',
comprimento: Number(peca.comprimento) || 0
});
setMotivo('');
setObservacoes('');
setTipoManual(null);
// Carregar histórico
setLoadingHistorico(true);
carregarRevisoesPeca(peca.id).then((revs) => {
setHistorico(revs);
setLoadingHistorico(false);
// Sugere o próximo número de revisão
const proximoNum = revs.length + 1;
setNumeroRevisao(`Rev ${String(proximoNum).padStart(2, '0')}`);
});
}
}, [peca, isOpen, carregarRevisoesPeca]);
if (!peca) return null;
// Analisa alterações em tempo real
const analise = detectarTipoRevisao(peca, formData);
const tipoEfetivo = tipoManual || analise.tipo;
const diffQtd = Number(formData.quantidade) - Number(peca.quantidade || 0);
const diffPeso = Number(formData.peso_unitario) - Number(peca.peso_unitario || 0);
const handleInputChange = (field: string, value: any) => {
setFormData((prev) => {
const next = { ...prev, [field]: value };
if (field === 'quantidade' || field === 'peso_unitario') {
const q = field === 'quantidade' ? Number(value) : prev.quantidade;
const p = field === 'peso_unitario' ? Number(value) : prev.peso_unitario;
next.peso_total = Number((q * p).toFixed(3));
}
return next;
});
};
const handleSalvarRevisao = async (e: React.FormEvent) => {
e.preventDefault();
if (!motivo.trim()) {
return;
}
const sucesso = await criarRevisao({
peca,
numeroRevisao,
tipoRevisao: tipoEfetivo,
descricaoMotivo: motivo,
observacoes,
novosDados: formData
});
if (sucesso) {
if (onPecaAtualizada) {
onPecaAtualizada();
}
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto bg-background text-foreground border-border p-6 shadow-xl">
<DialogHeader className="border-b border-border pb-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-500">
<Edit3 className="w-5 h-5" />
</div>
<div>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<span>Gestão de Revisões da Peça</span>
<Badge variant="outline" className="font-mono text-amber-600 dark:text-amber-400 border-amber-400/40 bg-amber-500/10 text-sm">
{peca.marca}
</Badge>
{historico.length > 0 && (
<Badge variant="secondary" className="bg-muted text-muted-foreground text-xs">
{historico.length} {historico.length === 1 ? 'revisão gravada' : 'revisões gravadas'}
</Badge>
)}
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs mt-0.5">
OF: <strong className="text-foreground">{peca.of_number}</strong> • Fase: <strong className="text-foreground">{peca.etapa_fase || 'N/A'}</strong> • Qtd Atual: <strong className="text-foreground">{peca.quantidade} un</strong> • Peso Atual: <strong className="text-foreground">{peca.peso_total || (peca.quantidade * peca.peso_unitario)} kg</strong>
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant={tipoEfetivo === 'critica' ? 'destructive' : 'secondary'}
className={`text-xs px-2.5 py-1 font-semibold ${
tipoEfetivo === 'critica'
? 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/30'
}`}
>
{tipoEfetivo === 'critica' ? '⚠️ Revisão Crítica' : 'ℹ️ Revisão Simples'}
</Badge>
</div>
</div>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="mt-4">
<TabsList className="grid grid-cols-2 bg-muted border border-border">
<TabsTrigger value="nova" className="data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm font-semibold">
<Edit3 className="w-4 h-4 mr-2" />
Nova Revisão (Editar Peça)
</TabsTrigger>
<TabsTrigger value="historico" className="data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm font-semibold">
<History className="w-4 h-4 mr-2" />
Histórico de Revisões ({historico.length})
</TabsTrigger>
</TabsList>
{/* ABA 1: NOVA REVISÃO */}
<TabsContent value="nova" className="mt-4 space-y-4">
<form onSubmit={handleSalvarRevisao} className="space-y-4">
{/* Metadados da Revisão */}
<div className="p-4 rounded-lg bg-card border border-border space-y-3 shadow-sm">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Info className="w-3.5 h-3.5 text-amber-500" />
Identificação da Revisão
</h4>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Classificação:</span>
<button
type="button"
onClick={() => setTipoManual('simples')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
tipoEfetivo === 'simples'
? 'bg-blue-600 text-white font-bold shadow-sm'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
Simples
</button>
<button
type="button"
onClick={() => setTipoManual('critica')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
tipoEfetivo === 'critica'
? 'bg-red-600 text-white font-bold shadow-sm'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
Crítica
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Label className="text-xs text-foreground">Número da Revisão *</Label>
<Input
value={numeroRevisao}
onChange={(e) => setNumeroRevisao(e.target.value)}
placeholder="Ex: Rev 01, Rev 02, Rev A"
className="font-mono text-sm mt-1 bg-background border-input text-foreground"
required
/>
</div>
<div className="md:col-span-2">
<Label className="text-xs text-foreground">Motivo / Justificativa da Revisão *</Label>
<Input
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
placeholder="Ex: Ajuste de projeto, alteração de quantidade pelo cliente..."
className="text-sm mt-1 bg-background border-input text-foreground"
required
/>
</div>
</div>
<div>
<Label className="text-xs text-foreground">Observações Adicionais (Opcional)</Label>
<Textarea
value={observacoes}
onChange={(e) => setObservacoes(e.target.value)}
placeholder="Detalhes adicionais, aprovações de engenharia, notas de fábrica..."
rows={2}
className="text-xs mt-1 bg-background border-input text-foreground"
/>
</div>
</div>
{/* Formulário CRUD da Peça */}
<div className="p-4 rounded-lg bg-card border border-border space-y-4 shadow-sm">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Edit3 className="w-3.5 h-3.5 text-blue-500" />
Campos da Peça para Revisão
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{/* Marca */}
<div>
<Label className="text-xs text-foreground flex items-center justify-between">
<span>Marca / Número da Peça *</span>
{formData.marca !== peca.marca && (
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-bold">Alterado!</span>
)}
</Label>
<Input
value={formData.marca}
onChange={(e) => handleInputChange('marca', e.target.value)}
className={`font-mono text-sm mt-1 bg-background border-input text-foreground ${
formData.marca !== peca.marca ? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-200' : ''
}`}
required
/>
</div>
{/* Quantidade */}
<div>
<Label className="text-xs text-foreground flex items-center justify-between">
<span>Quantidade *</span>
{diffQtd !== 0 && (
<span className={`text-[10px] font-bold ${diffQtd > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{diffQtd > 0 ? `+${diffQtd}` : `${diffQtd}`} un
</span>
)}
</Label>
<Input
type="number"
min={1}
step="1"
value={formData.quantidade}
onChange={(e) => handleInputChange('quantidade', Number(e.target.value))}
className={`font-mono text-sm mt-1 bg-background border-input text-foreground ${
diffQtd !== 0
? diffQtd > 0
? 'border-green-500 bg-green-500/10 text-green-700 dark:text-green-200'
: 'border-red-500 bg-red-500/10 text-red-700 dark:text-red-200'
: ''
}`}
required
/>
</div>
{/* Peso Unitário */}
<div>
<Label className="text-xs text-foreground flex items-center justify-between">
<span>Peso Unitário (kg) *</span>
{diffPeso !== 0 && (
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-bold">Alterado</span>
)}
</Label>
<Input
type="number"
step="0.001"
value={formData.peso_unitario}
onChange={(e) => handleInputChange('peso_unitario', Number(e.target.value))}
className="font-mono text-sm mt-1 bg-background border-input text-foreground"
required
/>
</div>
{/* Peso Total (calculado) */}
<div>
<Label className="text-xs text-foreground">Peso Total (kg)</Label>
<Input
type="number"
value={formData.peso_total}
readOnly
disabled
className="font-mono text-sm mt-1 bg-muted border-border text-muted-foreground cursor-not-allowed"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{/* Descrição */}
<div className="md:col-span-2">
<Label className="text-xs text-foreground">Descrição da Peça</Label>
<Input
value={formData.descricao}
onChange={(e) => handleInputChange('descricao', e.target.value)}
placeholder="Ex: Viga I, Pilar Tubular, Perfil Soldado..."
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
{/* Fase / Etapa */}
<div>
<Label className="text-xs text-foreground">Fase / Etapa</Label>
<Input
value={formData.etapa_fase}
onChange={(e) => handleInputChange('etapa_fase', e.target.value)}
placeholder="Ex: 1, 2, Cobertura..."
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
{/* Comprimento */}
<div>
<Label className="text-xs text-foreground">Comprimento (mm)</Label>
<Input
type="number"
value={formData.comprimento}
onChange={(e) => handleInputChange('comprimento', Number(e.target.value))}
placeholder="Ex: 6000"
className="text-sm mt-1 bg-background border-input text-foreground font-mono"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<Label className="text-xs text-foreground">Material / Aço</Label>
<Input
value={formData.material}
onChange={(e) => handleInputChange('material', e.target.value)}
placeholder="Ex: ASTM A36, A572 GR50"
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
<div>
<Label className="text-xs text-foreground">Perfil Principal</Label>
<Input
value={formData.perfil_principal}
onChange={(e) => handleInputChange('perfil_principal', e.target.value)}
placeholder="Ex: W250x32.7, RHS150x6.3"
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
<div>
<Label className="text-xs text-foreground">Tratamento Superficial</Label>
<Input
value={formData.tratamento_superficial}
onChange={(e) => handleInputChange('tratamento_superficial', e.target.value)}
placeholder="Ex: Pintura Epóxi, Galvanizado"
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
</div>
</div>
{/* CARD DE IMPACTO OPERACIONAL / REGRAS DE NEGÓCIO */}
{analise.camposAlterados.length > 0 && (
<div className={`p-4 rounded-lg border shadow-sm ${
tipoEfetivo === 'critica'
? 'bg-red-500/10 border-red-500/30 text-red-900 dark:text-red-200'
: 'bg-blue-500/10 border-blue-500/30 text-blue-900 dark:text-blue-200'
}`}>
<div className="flex items-start gap-3">
<ShieldAlert className={`w-5 h-5 mt-0.5 shrink-0 ${
tipoEfetivo === 'critica' ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
}`} />
<div className="space-y-2 text-xs">
<div className="font-bold uppercase tracking-wider">
{tipoEfetivo === 'critica'
? 'Impacto Crítico na Linha de Produção & Apontamentos'
: 'Revisão Informativa (Campos Subjetivos)'}
</div>
<div className="space-y-1">
{diffQtd > 0 && (
<div className="flex items-center gap-1.5 text-emerald-700 dark:text-emerald-400 font-medium">
<TrendingUp className="w-4 h-4 shrink-0" />
<span>
<strong>Aumento de quantidade (+{diffQtd} un):</strong> A diferença excedente de {diffQtd} un nascerá automaticamente no 1º processo de fabricação (Corte/Detalhamento) para novos apontamentos.
</span>
</div>
)}
{diffQtd < 0 && (
<div className="flex items-center gap-1.5 text-rose-700 dark:text-rose-400 font-medium">
<TrendingDown className="w-4 h-4 shrink-0" />
<span>
<strong>Redução de quantidade ({diffQtd} un):</strong> O novo total será de {formData.quantidade} un. Os processos que possuírem apontamentos superiores a {formData.quantidade} un serão revisados automaticamente para o novo teto.
</span>
</div>
)}
{formData.marca !== peca.marca && (
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-300 font-medium">
<ArrowRight className="w-4 h-4 shrink-0" />
<span>
<strong>Alteração de marca/código ({peca.marca} ➔ {formData.marca}):</strong> A rastreabilidade da peça será atualizada e preservada em apontamentos e romaneios de expedição.
</span>
</div>
)}
{analise.detalhes.map((det, idx) => (
<div key={idx} className="text-foreground/80">
• {det}
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Botões de Ação */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
className="border-border text-foreground hover:bg-muted"
>
Cancelar
</Button>
<Button
type="submit"
disabled={loading || !motivo.trim() || analise.camposAlterados.length === 0}
className="bg-amber-600 hover:bg-amber-500 text-white font-semibold flex items-center gap-2 shadow-sm"
>
<Save className="w-4 h-4" />
{loading ? 'Processando Revisão...' : 'Salvar Revisão e Atualizar Peça'}
</Button>
</div>
</form>
</TabsContent>
{/* ABA 2: HISTÓRICO DE REVISÕES */}
<TabsContent value="historico" className="mt-4 space-y-3">
{loadingHistorico ? (
<div className="p-8 text-center text-muted-foreground">
Carregando histórico de revisões...
</div>
) : historico.length === 0 ? (
<div className="p-8 text-center border border-dashed border-border rounded-lg text-muted-foreground bg-card/50">
<History className="w-8 h-8 mx-auto mb-2 text-muted-foreground/60" />
<p className="font-medium">Nenhuma revisão registrada para esta peça até o momento.</p>
<p className="text-xs text-muted-foreground mt-1">
Ao realizar a primeira alteração pela aba &quot;Nova Revisão&quot;, o histórico completo será guardado aqui.
</p>
</div>
) : (
<div className="space-y-3">
{historico.map((rev) => (
<div
key={rev.id}
className="p-4 rounded-lg bg-card border border-border hover:border-primary/40 transition-colors space-y-2.5 shadow-sm"
>
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono font-bold bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-400/40">
{rev.numero_revisao}
</Badge>
<Badge
className={`text-xs ${
rev.tipo_revisao === 'critica'
? 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/30'
}`}
>
{rev.tipo_revisao === 'critica' ? 'Crítica' : 'Simples'}
</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{rev.data_revisao
? format(new Date(rev.data_revisao), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
: 'N/A'}
</span>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<User className="w-3.5 h-3.5" />
<span>{rev.usuario_nome || rev.usuario_email || 'Usuário'}</span>
</div>
</div>
{/* Motivo */}
<div className="text-sm font-medium text-foreground">
<strong>Motivo:</strong> {rev.descricao_motivo}
</div>
{rev.observacoes && (
<div className="text-xs text-muted-foreground italic">
<strong>Obs:</strong> {rev.observacoes}
</div>
)}
{/* Comparativo de Campos Alterados */}
{rev.campos_alterados && rev.campos_alterados.length > 0 && (
<div className="p-2.5 rounded bg-muted/60 border border-border text-xs space-y-1">
<div className="font-semibold text-muted-foreground text-[11px] uppercase tracking-wider mb-1">
Modificações Realizadas:
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{rev.campos_alterados.map((campo) => {
const ant = rev.dados_anteriores?.[campo];
const nov = rev.dados_novos?.[campo];
return (
<div key={campo} className="p-1.5 rounded bg-background border border-border">
<span className="text-muted-foreground font-mono text-[10px] block uppercase">
{campo}
</span>
<div className="flex items-center gap-1.5 text-xs font-mono mt-0.5">
<span className="text-rose-600 dark:text-rose-400 line-through truncate max-w-[80px]">
{String(ant ?? '-')}
</span>
<ArrowRight className="w-3 h-3 text-muted-foreground shrink-0" />
<span className="text-emerald-600 dark:text-emerald-400 font-bold truncate max-w-[80px]">
{String(nov ?? '-')}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Impacto em Apontamentos */}
{rev.impacto_processos && rev.impacto_processos.resumo && rev.impacto_processos.resumo.length > 0 && (
<div className="p-2 rounded bg-amber-500/10 border border-amber-500/20 text-[11px] text-amber-800 dark:text-amber-300 space-y-0.5">
<div className="font-bold flex items-center gap-1 text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3 h-3" />
Ajustes de Produção Aplicados:
</div>
{rev.impacto_processos.resumo.map((res: string, i: number) => (
<div key={i}>• {res}</div>
))}
</div>
)}
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
+37 -4
View File
@@ -14,7 +14,8 @@ import {
Layers, Layers,
Loader2, Loader2,
ExternalLink, ExternalLink,
Minus Minus,
FileText
} from 'lucide-react'; } from 'lucide-react';
import { Peca } from '@/hooks/usePecas'; import { Peca } from '@/hooks/usePecas';
import { useComponentesPeca } from '@/hooks/useComponentesPeca'; import { useComponentesPeca } from '@/hooks/useComponentesPeca';
@@ -28,6 +29,7 @@ interface TableRowProps {
onOpenComponentPopup: () => void; onOpenComponentPopup: () => void;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void; onDelete?: () => void;
onOpenRevisao?: () => void;
} }
export function TableRow({ export function TableRow({
@@ -36,13 +38,15 @@ export function TableRow({
onSelect, onSelect,
onOpenComponentPopup, onOpenComponentPopup,
onEdit, onEdit,
onDelete onDelete,
onOpenRevisao
}: TableRowProps) { }: TableRowProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const { labels } = useAppLabels(); const { labels } = useAppLabels();
const { componentes, loading } = useComponentesPeca(isExpanded ? peca.id : ''); const { componentes, loading } = useComponentesPeca(isExpanded ? peca.id : '');
const hasSubComponents = peca.tem_componentes; const hasSubComponents = peca.tem_componentes;
const hasRevisao = Boolean(peca.tem_revisao || (peca.qtd_revisoes && peca.qtd_revisoes > 0));
const toggleExpand = (e: React.MouseEvent) => { const toggleExpand = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -88,9 +92,26 @@ export function TableRow({
{peca.etapa_fase} {peca.etapa_fase}
</TableCell> </TableCell>
<TableCell className="w-14 px-2 text-foreground text-xs font-bold font-mono"> <TableCell className="w-14 px-2 text-xs font-bold font-mono">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span>{peca.marca}</span> <span
onClick={hasRevisao && onOpenRevisao ? (e) => {
e.stopPropagation();
onOpenRevisao();
} : undefined}
className={`${
hasRevisao
? 'text-red-600 dark:text-orange-400 font-extrabold cursor-pointer hover:underline'
: 'text-foreground'
}`}
title={
hasRevisao
? `Peça em revisão (${peca.revisao_atual || 'Rev'}). Clique para consultar.`
: undefined
}
>
{peca.marca}
</span>
{hasSubComponents && ( {hasSubComponents && (
<span <span
onClick={toggleExpand} onClick={toggleExpand}
@@ -168,6 +189,18 @@ export function TableRow({
<Package className="h-3.5 w-3.5" /> <Package className="h-3.5 w-3.5" />
</Button> </Button>
{onOpenRevisao && (
<Button
variant="ghost"
size="sm"
onClick={onOpenRevisao}
title="Gestão e Histórico de Revisões da Peça"
className="h-6 w-6 p-0 text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 hover:bg-amber-500/10"
>
<FileText className="h-3 w-3" />
</Button>
)}
{onEdit && ( {onEdit && (
<Button <Button
variant="ghost" variant="ghost"
+10 -8
View File
@@ -27,12 +27,6 @@ export const AppSidebarMenuItem: React.FC<SidebarMenuItemProps> = ({
onMenuItemClick, onMenuItemClick,
getIconProps getIconProps
}) => { }) => {
const canAccess = canAccessItem(item.key);
if (!canAccess && !isAdmin) {
return null; // Don't show item if no access
}
// Filtrar subitens permitidos para o usuário logado // Filtrar subitens permitidos para o usuário logado
const subItemsPermitidos = (item.subItems || []).filter((subItem) => { const subItemsPermitidos = (item.subItems || []).filter((subItem) => {
if (subItem.url === "/admin/theme-customization" && !isAdmin) { if (subItem.url === "/admin/theme-customization" && !isAdmin) {
@@ -42,8 +36,16 @@ export const AppSidebarMenuItem: React.FC<SidebarMenuItemProps> = ({
}); });
// Se o item for apenas um agrupador de subitens e todos estiverem bloqueados, não renderizar o agrupador // Se o item for apenas um agrupador de subitens e todos estiverem bloqueados, não renderizar o agrupador
if (item.subItems && item.subItems.length > 0 && subItemsPermitidos.length === 0 && !isAdmin) { if (item.subItems && item.subItems.length > 0) {
return null; if (!isAdmin && subItemsPermitidos.length === 0) {
return null;
}
} else {
// Se for item direto sem subitens
const canAccess = canAccessItem(item.key);
if (!canAccess && !isAdmin) {
return null;
}
} }
return ( return (
+8 -1
View File
@@ -18,7 +18,8 @@ import {
Settings, Settings,
Shield, Shield,
UserCog, UserCog,
Cog Cog,
ScanSearch
} from "lucide-react"; } from "lucide-react";
import { MenuItem, MenuGroup } from './types'; import { MenuItem, MenuGroup } from './types';
@@ -156,6 +157,12 @@ export const menuGroups: MenuGroup[] = [
url: "/visualizador-3d", url: "/visualizador-3d",
icon: Box, icon: Box,
}, },
{
key: "analisador-3d",
title: "Analisador 3D",
url: "/analisador-3d",
icon: ScanSearch,
},
{ {
key: "painel-industrial", key: "painel-industrial",
title: "Painel Industrial", title: "Painel Industrial",
@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Sparkles, Save, CheckCircle2 } from 'lucide-react';
import { SMART_SUBMODULES } from '@/types/smartPermissions';
interface CriarSmartTemplateModalProps {
isOpen: boolean;
onClose: () => void;
currentPermissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>;
onSalvarTemplate: (
nome: string,
descricao: string,
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
) => boolean;
}
export const CriarSmartTemplateModal: React.FC<CriarSmartTemplateModalProps> = ({
isOpen,
onClose,
currentPermissions,
onSalvarTemplate,
}) => {
const [nome, setNome] = useState('');
const [descricao, setDescricao] = useState('');
const handleSalvar = (e: React.FormEvent) => {
e.preventDefault();
const ok = onSalvarTemplate(nome, descricao, currentPermissions);
if (ok) {
setNome('');
setDescricao('');
onClose();
}
};
// Contagem de permissões ativas
const totalVisualizar = Object.values(currentPermissions).filter((p) => p.can_view).length;
const totalEditar = Object.values(currentPermissions).filter((p) => p.can_edit).length;
const totalExcluir = Object.values(currentPermissions).filter((p) => p.can_delete).length;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md bg-card border-border shadow-2xl rounded-2xl p-6">
<DialogHeader className="space-y-1.5">
<div className="flex items-center gap-2 text-emerald-500">
<div className="p-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<Sparkles className="h-5 w-5" />
</div>
<DialogTitle className="text-base font-bold text-foreground">
Criar Novo Template do Modo Smart
</DialogTitle>
</div>
<DialogDescription className="text-xs text-muted-foreground">
Salve a configuração atual de permissões como um template reutilizável para aplicar em outros operadores com 1 clique.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSalvar} className="space-y-4 py-2">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-slate-200">
Nome do Template <span className="text-rose-500">*</span>
</Label>
<Input
placeholder="Ex: Soldador Nível 2, Inspetor de Pintura, Montador..."
value={nome}
onChange={(e) => setNome(e.target.value)}
required
className="h-9 text-xs bg-background border-slate-300 dark:border-border text-foreground font-medium"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-slate-200">
Descrição Curta (Opcional)
</Label>
<Input
placeholder="Ex: Permissões recomendadas para operadores da bancada de solda"
value={descricao}
onChange={(e) => setDescricao(e.target.value)}
className="h-9 text-xs bg-background border-slate-300 dark:border-border text-foreground font-medium"
/>
</div>
{/* RESUMO DAS PERMISSÕES CAPTURADAS */}
<div className="p-3 rounded-xl border border-border bg-muted/40 space-y-2">
<span className="text-[11px] font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider block">
Permissões capturadas da tela:
</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-sky-500/10 text-sky-700 dark:text-sky-400 border-sky-500/30 text-[10px]">
{totalVisualizar} Visualizar
</Badge>
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/30 text-[10px]">
{totalEditar} Apontar/Editar
</Badge>
<Badge variant="outline" className="bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/30 text-[10px]">
{totalExcluir} Excluir
</Badge>
</div>
</div>
<DialogFooter className="pt-2 flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
className="text-xs h-8"
>
Cancelar
</Button>
<Button
type="submit"
size="sm"
className="text-xs h-8 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold flex items-center gap-1.5 shadow-sm"
>
<Save className="h-3.5 w-3.5" />
Salvar Template
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -2,11 +2,13 @@ import React, { useState } from 'react';
import { useMatrizAcessos, UsuarioMatriz } from '@/hooks/useMatrizAcessos'; import { useMatrizAcessos, UsuarioMatriz } from '@/hooks/useMatrizAcessos';
import { MatrizGrid } from './MatrizGrid'; import { MatrizGrid } from './MatrizGrid';
import { MatrizTemplatesModal } from './MatrizTemplatesModal'; import { MatrizTemplatesModal } from './MatrizTemplatesModal';
import { MatrizSmartAvancadoModal } from './MatrizSmartAvancadoModal';
import { MatrizGrupoAvancadoModal } from './MatrizGrupoAvancadoModal';
import { SimuladorVisaoBar } from './SimuladorVisaoBar'; import { SimuladorVisaoBar } from './SimuladorVisaoBar';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Search, RefreshCw, Users, Shield, Factory, Sparkles } from 'lucide-react'; import { Search, RefreshCw, Users, Shield, Factory, Sparkles, SlidersHorizontal } from 'lucide-react';
interface MatrizAcessosPainelProps { interface MatrizAcessosPainelProps {
onOpenNovoUsuario?: () => void; onOpenNovoUsuario?: () => void;
@@ -28,6 +30,8 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
const [busca, setBusca] = useState(''); const [busca, setBusca] = useState('');
const [selectedUserForTemplate, setSelectedUserForTemplate] = useState<UsuarioMatriz | null>(null); const [selectedUserForTemplate, setSelectedUserForTemplate] = useState<UsuarioMatriz | null>(null);
const [smartAvancadoOpen, setSmartAvancadoOpen] = useState(false);
const [grupoAvancadoKey, setGrupoAvancadoKey] = useState<string | null>(null);
// Filtragem por nome, email ou cargo // Filtragem por nome, email ou cargo
const usuariosFiltrados = usuarios.filter((u) => { const usuariosFiltrados = usuarios.filter((u) => {
@@ -105,6 +109,16 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
</div> </div>
<div className="flex items-center gap-2 w-full sm:w-auto justify-end"> <div className="flex items-center gap-2 w-full sm:w-auto justify-end">
<Button
size="sm"
variant="outline"
onClick={() => setSmartAvancadoOpen(true)}
className="text-xs h-9 border-emerald-500/30 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 font-semibold"
>
<SlidersHorizontal className="h-3.5 w-3.5 mr-1.5" />
Avançado Modo Smart
</Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -139,6 +153,8 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
grupos={gruposModulos} grupos={gruposModulos}
onToggleNivel={toggleNivel} onToggleNivel={toggleNivel}
onOpenTemplates={(user) => setSelectedUserForTemplate(user)} onOpenTemplates={(user) => setSelectedUserForTemplate(user)}
onOpenSmartAvancado={() => setSmartAvancadoOpen(true)}
onOpenGrupoAvancado={(key) => setGrupoAvancadoKey(key)}
/> />
)} )}
@@ -149,6 +165,19 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
onClose={() => setSelectedUserForTemplate(null)} onClose={() => setSelectedUserForTemplate(null)}
onSelectTemplate={aplicarTemplate} onSelectTemplate={aplicarTemplate}
/> />
{/* 6. MODAL DE GESTÃO AVANÇADA MODO SMART */}
<MatrizSmartAvancadoModal
isOpen={smartAvancadoOpen}
onClose={() => setSmartAvancadoOpen(false)}
/>
{/* 7. MODAL DE GESTÃO AVANÇADA POR CATEGORIA / GRUPO */}
<MatrizGrupoAvancadoModal
grupoKey={grupoAvancadoKey}
isOpen={Boolean(grupoAvancadoKey)}
onClose={() => setGrupoAvancadoKey(null)}
/>
</div> </div>
); );
}; };
+38 -2
View File
@@ -3,7 +3,7 @@ import { UsuarioMatriz, GrupoRecurso, NivelAcesso } from '@/hooks/useMatrizAcess
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Sparkles, Eye, Edit3, ShieldAlert } from 'lucide-react'; import { Sparkles, Eye, Edit3, ShieldAlert, SlidersHorizontal } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface MatrizGridProps { interface MatrizGridProps {
@@ -11,6 +11,8 @@ interface MatrizGridProps {
grupos: GrupoRecurso[]; grupos: GrupoRecurso[];
onToggleNivel: (userId: string, resourceKey: string, tipo: 'V' | 'C') => void; onToggleNivel: (userId: string, resourceKey: string, tipo: 'V' | 'C') => void;
onOpenTemplates: (user: UsuarioMatriz) => void; onOpenTemplates: (user: UsuarioMatriz) => void;
onOpenSmartAvancado?: () => void;
onOpenGrupoAvancado?: (grupoKey: string) => void;
} }
export const MatrizGrid: React.FC<MatrizGridProps> = ({ export const MatrizGrid: React.FC<MatrizGridProps> = ({
@@ -18,6 +20,8 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
grupos, grupos,
onToggleNivel, onToggleNivel,
onOpenTemplates, onOpenTemplates,
onOpenSmartAvancado,
onOpenGrupoAvancado,
}) => { }) => {
// Cores de fundo e borda para os grupos // Cores de fundo e borda para os grupos
const corGrupoHeader: Record<string, string> = { const corGrupoHeader: Record<string, string> = {
@@ -29,6 +33,15 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
rose: 'bg-rose-500/10 text-rose-400 border-rose-500/30', rose: 'bg-rose-500/10 text-rose-400 border-rose-500/30',
}; };
const corBotaoAvancado: Record<string, string> = {
emerald: 'bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40',
sky: 'bg-sky-500/20 hover:bg-sky-500/30 text-sky-300 border border-sky-500/40',
indigo: 'bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 border border-indigo-500/40',
amber: 'bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40',
teal: 'bg-teal-500/20 hover:bg-teal-500/30 text-teal-300 border border-teal-500/40',
rose: 'bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 border border-rose-500/40',
};
return ( return (
<div className="rounded-2xl border border-border bg-card overflow-hidden shadow-sm"> <div className="rounded-2xl border border-border bg-card overflow-hidden shadow-sm">
<div className="overflow-x-auto max-w-full"> <div className="overflow-x-auto max-w-full">
@@ -48,8 +61,31 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
colSpan={grupo.recursos.length} colSpan={grupo.recursos.length}
className={`p-2.5 text-center border-r border-border/40 text-xs font-bold ${corGrupoHeader[grupo.cor]}`} className={`p-2.5 text-center border-r border-border/40 text-xs font-bold ${corGrupoHeader[grupo.cor]}`}
> >
<div className="flex items-center justify-center gap-1.5"> <div className="flex items-center justify-center gap-2 flex-wrap">
<span>{grupo.titulo}</span> <span>{grupo.titulo}</span>
{grupo.key === 'smart' && onOpenSmartAvancado ? (
<Button
size="sm"
variant="secondary"
onClick={onOpenSmartAvancado}
className="h-6 px-2 py-0 text-[10px] bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 rounded-md font-semibold flex items-center gap-1 shadow-sm transition-transform active:scale-95"
title="Abrir Gestão Avançada de Privilégios e Notificações do Modo Smart"
>
<SlidersHorizontal className="h-3 w-3" />
Avançado
</Button>
) : onOpenGrupoAvancado ? (
<Button
size="sm"
variant="secondary"
onClick={() => onOpenGrupoAvancado(grupo.key)}
className={`h-6 px-2 py-0 text-[10px] ${corBotaoAvancado[grupo.cor] || 'bg-primary/20 text-primary border-primary/40'} rounded-md font-semibold flex items-center gap-1 shadow-sm transition-transform active:scale-95`}
title={`Abrir Regras & Governança Avançada de ${grupo.titulo}`}
>
<SlidersHorizontal className="h-3 w-3" />
Avançado
</Button>
) : null}
</div> </div>
<span className="text-[10px] font-normal opacity-80 block truncate"> <span className="text-[10px] font-normal opacity-80 block truncate">
{grupo.descricao} {grupo.descricao}
@@ -0,0 +1,409 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
SlidersHorizontal,
Search,
CheckCircle2,
Save,
RotateCcw,
Shield,
Factory,
Box,
Truck,
HardHat,
Sparkles,
Lock,
ArrowUpDown,
Layers,
AlertTriangle,
Download,
Network,
UploadCloud,
Compass,
Barcode,
PackageCheck,
CheckSquare,
Activity,
Settings,
Crown,
Wrench,
Calendar,
Trash2,
FileSpreadsheet,
PlusCircle,
PlusSquare,
Edit3,
FileText,
Printer,
FileInput,
ShoppingCart,
CheckCheck,
AlertCircle,
Settings2,
Users,
ShieldCheck,
KeyRound,
} from 'lucide-react';
import { useGrupoAvancadoPermissions } from '@/hooks/useGrupoAvancadoPermissions';
interface MatrizGrupoAvancadoModalProps {
grupoKey: string | null;
isOpen: boolean;
onClose: () => void;
}
export const MatrizGrupoAvancadoModal: React.FC<MatrizGrupoAvancadoModalProps> = ({
grupoKey,
isOpen,
onClose,
}) => {
const {
config,
loading,
saving,
users,
selectedUserId,
setSelectedUserId,
selectedUser,
toggleRegra,
aplicarTemplate,
salvarConfiguracaoUsuario,
recarregar,
} = useGrupoAvancadoPermissions(isOpen ? grupoKey : null);
const [busca, setBusca] = useState('');
if (!config) return null;
const usersFiltrados = users.filter((u) => {
const termo = busca.toLowerCase();
return (
u.fullName.toLowerCase().includes(termo) ||
u.email.toLowerCase().includes(termo) ||
u.functionName.toLowerCase().includes(termo)
);
});
const corBadge: Record<string, string> = {
sky: 'bg-sky-500/15 text-sky-400 border-sky-500/40',
indigo: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/40',
amber: 'bg-amber-500/15 text-amber-400 border-amber-500/40',
teal: 'bg-teal-500/15 text-teal-400 border-teal-500/40',
rose: 'bg-rose-500/15 text-rose-400 border-rose-500/40',
};
const corFundoIcone: Record<string, string> = {
sky: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
indigo: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
amber: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
teal: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
rose: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
};
const getIconComponent = (icone: string) => {
switch (icone) {
case 'Calendar':
return <Calendar className="h-4 w-4 text-sky-400" />;
case 'Lock':
return <Lock className="h-4 w-4 text-rose-400" />;
case 'ArrowUpDown':
return <ArrowUpDown className="h-4 w-4 text-amber-400" />;
case 'Layers':
return <Layers className="h-4 w-4 text-indigo-400" />;
case 'Trash2':
return <Trash2 className="h-4 w-4 text-rose-400" />;
case 'FileSpreadsheet':
return <FileSpreadsheet className="h-4 w-4 text-emerald-400" />;
case 'PlusCircle':
return <PlusCircle className="h-4 w-4 text-emerald-400" />;
case 'PlusSquare':
return <PlusSquare className="h-4 w-4 text-amber-400" />;
case 'Edit3':
return <Edit3 className="h-4 w-4 text-sky-400" />;
case 'UploadCloud':
return <UploadCloud className="h-4 w-4 text-teal-400" />;
case 'Network':
return <Network className="h-4 w-4 text-indigo-400" />;
case 'Download':
return <Download className="h-4 w-4 text-sky-400" />;
case 'FileText':
return <FileText className="h-4 w-4 text-slate-300" />;
case 'PackageCheck':
return <PackageCheck className="h-4 w-4 text-teal-400" />;
case 'CheckSquare':
return <CheckSquare className="h-4 w-4 text-emerald-400" />;
case 'RotateCcw':
return <RotateCcw className="h-4 w-4 text-amber-400" />;
case 'Printer':
return <Printer className="h-4 w-4 text-indigo-400" />;
case 'SlidersHorizontal':
return <SlidersHorizontal className="h-4 w-4 text-cyan-400" />;
case 'FileInput':
return <FileInput className="h-4 w-4 text-emerald-400" />;
case 'ShoppingCart':
return <ShoppingCart className="h-4 w-4 text-orange-400" />;
case 'CheckCheck':
return <CheckCheck className="h-4 w-4 text-teal-400" />;
case 'AlertCircle':
return <AlertCircle className="h-4 w-4 text-amber-400" />;
case 'Settings2':
return <Settings2 className="h-4 w-4 text-indigo-400" />;
case 'Users':
return <Users className="h-4 w-4 text-sky-400" />;
case 'ShieldCheck':
return <ShieldCheck className="h-4 w-4 text-emerald-400" />;
case 'KeyRound':
return <KeyRound className="h-4 w-4 text-amber-400" />;
case 'Activity':
return <Activity className="h-4 w-4 text-rose-400" />;
case 'Settings':
return <Settings className="h-4 w-4 text-slate-400" />;
case 'Crown':
return <Crown className="h-3.5 w-3.5 text-amber-400" />;
case 'Shield':
return <Shield className="h-3.5 w-3.5 text-sky-400" />;
case 'Wrench':
return <Wrench className="h-3.5 w-3.5 text-emerald-400" />;
case 'Compass':
return <Compass className="h-3.5 w-3.5 text-cyan-400" />;
case 'Barcode':
return <Barcode className="h-3.5 w-3.5 text-amber-400" />;
case 'Box':
return <Box className="h-3.5 w-3.5 text-indigo-400" />;
default:
return <Sparkles className="h-4 w-4 text-amber-400" />;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-5xl w-[95vw] max-h-[92vh] h-[850px] p-0 flex flex-col bg-card border-border shadow-2xl rounded-2xl overflow-hidden">
{/* CABEÇALHO DO MODAL */}
<div className="px-6 py-4 border-b border-border bg-muted/40 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl border ${corFundoIcone[config.cor]}`}>
<SlidersHorizontal className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base sm:text-lg font-bold flex items-center gap-2 text-foreground">
Regras & Governança Avançada
<Badge variant="outline" className={`text-[10px] uppercase tracking-wider font-bold ${corBadge[config.cor]}`}>
{config.titulo}
</Badge>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{config.descricao}
</DialogDescription>
</div>
</div>
</div>
{/* CORPO PRINCIPAL: SIDEBAR DE USUÁRIOS + ÁREA DE REGRAS */}
<div className="flex-1 flex overflow-hidden">
{/* SIDEBAR DE USUÁRIOS */}
<div className="w-[280px] sm:w-[310px] border-r border-border bg-background/60 flex flex-col shrink-0">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Buscar colaborador..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 text-xs h-8 bg-card border-slate-300 dark:border-border text-foreground font-medium"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border/40 p-1.5 space-y-0.5">
{loading ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Carregando colaboradores...
</div>
) : usersFiltrados.length === 0 ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Nenhum colaborador encontrado.
</div>
) : (
usersFiltrados.map((user) => {
const isSelected = user.userId === selectedUserId;
const totalAtivas = Object.values(user.regras).filter(Boolean).length;
return (
<button
key={user.userId}
onClick={() => setSelectedUserId(user.userId)}
className={`w-full text-left p-2.5 rounded-xl transition-all flex items-center gap-2.5 ${
isSelected
? `${corBadge[config.cor]} border shadow-sm`
: 'hover:bg-muted/60 text-muted-foreground hover:text-foreground'
}`}
>
<Avatar className="h-8 w-8 rounded-lg border border-border shrink-0">
<AvatarImage src={user.profileImageUrl || ''} />
<AvatarFallback className="text-[10px] font-bold bg-primary/10 text-primary">
{user.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1">
<span className="text-xs font-semibold text-foreground truncate">
{user.fullName}
</span>
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
{totalAtivas}/{config.regras.length}
</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span className="truncate max-w-[120px]">{user.functionName}</span>
<span>•</span>
<span className="truncate">{user.privilegeName}</span>
</div>
</div>
</button>
);
})
)}
</div>
</div>
{/* ÁREA DE CONFIGURAÇÃO DO USUÁRIO */}
<div className="flex-1 flex flex-col bg-card overflow-hidden">
{selectedUser ? (
<div className="flex-1 flex flex-col overflow-hidden">
{/* CABEÇALHO DO USUÁRIO SELECIONADO */}
<div className="p-4 border-b border-border/60 bg-muted/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 rounded-xl border border-border">
<AvatarImage src={selectedUser.profileImageUrl || ''} />
<AvatarFallback className="text-xs font-bold bg-primary/10 text-primary">
{selectedUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-sm font-bold text-foreground">
{selectedUser.fullName}
</h3>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span>{selectedUser.email}</span>
<span>•</span>
<Badge variant="secondary" className="text-[10px] py-0 h-4">
{selectedUser.functionName}
</Badge>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={salvarConfiguracaoUsuario}
disabled={saving}
className="h-8 text-xs font-semibold bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1.5 shadow-sm"
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Gravando...' : 'Salvar Regras'}
</Button>
</div>
</div>
{/* TEMPLATES RÁPIDOS */}
<div className="px-6 py-3 border-b border-border/40 bg-muted/10">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-semibold text-muted-foreground mr-1 flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5 text-amber-400" />
Presets Rápidos:
</span>
{config.templates.map((tpl) => (
<Button
key={tpl.id}
size="sm"
variant="outline"
onClick={() => aplicarTemplate(tpl.id)}
className="h-7 text-[11px] px-2.5 py-0 rounded-lg hover:border-primary/50 flex items-center gap-1.5 bg-background/80"
title={tpl.descricao}
>
{getIconComponent(tpl.icone)}
{tpl.nome}
</Button>
))}
</div>
</div>
{/* LISTAGEM DE REGRAS GRANULARES */}
<div className="flex-1 overflow-y-auto p-6 space-y-3">
<div className="grid grid-cols-1 gap-3">
{config.regras.map((regra) => {
const ativo = Boolean(selectedUser.regras[regra.key]);
return (
<div
key={regra.key}
onClick={() => toggleRegra(regra.key)}
className={`p-3.5 rounded-xl border transition-all cursor-pointer flex items-center justify-between gap-4 ${
ativo
? 'bg-muted/40 border-primary/40 shadow-sm'
: 'bg-background border-border/50 opacity-70 hover:opacity-100 hover:border-border'
}`}
>
<div className="flex items-center gap-3.5 min-w-0">
<div
className={`p-2 rounded-lg border shrink-0 ${
ativo
? `${corBadge[config.cor]} border`
: 'bg-muted text-muted-foreground border-border'
}`}
>
{getIconComponent(regra.icone)}
</div>
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-foreground">
{regra.nome}
</span>
{ativo && (
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30 text-[9px] h-4 py-0">
LIBERADO
</Badge>
)}
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{regra.descricao}
</p>
</div>
</div>
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Switch
checked={ativo}
onCheckedChange={() => toggleRegra(regra.key)}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-xs">
Selecione um colaborador ao lado para configurar as regras avançadas.
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,603 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
SlidersHorizontal,
Search,
CheckCircle2,
Save,
RotateCcw,
Shield,
Factory,
Eye,
Edit3,
Trash2,
Mail,
Phone,
MessageSquare,
AlertTriangle,
FileText,
Layers,
Flame,
Zap,
Box,
ExternalLink,
Truck,
Wrench,
FileSpreadsheet,
Info,
Sparkles,
Lock,
Plus,
BookmarkPlus,
X,
} from 'lucide-react';
import { useSmartPermissions } from '@/hooks/useSmartPermissions';
import { SMART_SUBMODULES, SmartCustomTemplate } from '@/types/smartPermissions';
import { CriarSmartTemplateModal } from './CriarSmartTemplateModal';
interface MatrizSmartAvancadoModalProps {
isOpen: boolean;
onClose: () => void;
}
export const MatrizSmartAvancadoModal: React.FC<MatrizSmartAvancadoModalProps> = ({
isOpen,
onClose,
}) => {
const {
loading,
saving,
users,
templates,
selectedUserId,
setSelectedUserId,
selectedUser,
togglePermission,
updateNotificationConfig,
salvarConfiguracaoUsuario,
aplicarTemplate,
criarNovoTemplate,
removerTemplateCustom,
recarregar,
} = useSmartPermissions();
const [busca, setBusca] = useState('');
const [modalCriarTemplateOpen, setModalCriarTemplateOpen] = useState(false);
const usersFiltrados = users.filter((u) => {
const termo = busca.toLowerCase();
return (
u.fullName.toLowerCase().includes(termo) ||
u.email.toLowerCase().includes(termo) ||
u.functionName.toLowerCase().includes(termo)
);
});
const getIconComponent = (icone: string) => {
switch (icone) {
case 'FileText':
return <FileText className="h-4 w-4 text-sky-500 dark:text-sky-400" />;
case 'Layers':
return <Layers className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />;
case 'Flame':
return <Flame className="h-4 w-4 text-orange-500 dark:text-orange-400" />;
case 'CheckCircle2':
return <CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />;
case 'Zap':
return <Zap className="h-4 w-4 text-amber-500 dark:text-amber-400" />;
case 'Box':
return <Box className="h-4 w-4 text-cyan-600 dark:text-cyan-400" />;
case 'ExternalLink':
return <ExternalLink className="h-4 w-4 text-purple-600 dark:text-purple-400" />;
case 'Truck':
return <Truck className="h-4 w-4 text-amber-600 dark:text-amber-500" />;
case 'Wrench':
return <Wrench className="h-4 w-4 text-teal-600 dark:text-teal-400" />;
case 'FileSpreadsheet':
return <FileSpreadsheet className="h-4 w-4 text-emerald-600 dark:text-emerald-500" />;
case 'Shield':
return <Shield className="h-3.5 w-3.5 text-rose-500 dark:text-rose-400" />;
case 'Factory':
return <Factory className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />;
case 'Eye':
return <Eye className="h-3.5 w-3.5 text-sky-600 dark:text-sky-400" />;
default:
return <Sparkles className="h-3.5 w-3.5 text-amber-500 dark:text-amber-400" />;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-6xl w-[96vw] max-h-[94vh] h-[900px] p-0 flex flex-col bg-card border-border shadow-2xl rounded-2xl overflow-hidden">
{/* CABEÇALHO DO MODAL */}
<div className="px-6 py-4 border-b border-border bg-muted/40 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<SlidersHorizontal className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base sm:text-lg font-bold flex items-center gap-2 text-foreground">
Gestão Avançada de Privilégios & Notificações
<Badge variant="outline" className="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-emerald-500/40 text-[10px] uppercase tracking-wider font-bold">
Modo Smart
</Badge>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
Controle granular de acesso por submódulo e canais de notificação (E-mail e WhatsApp via única).
</DialogDescription>
</div>
</div>
</div>
{/* CORPO PRINCIPAL COM SIDEBAR DE USUÁRIOS E ÁREA DE CONFIGURAÇÃO */}
<div className="flex-1 flex overflow-hidden">
{/* SIDEBAR DE USUÁRIOS */}
<div className="w-[280px] sm:w-[310px] border-r border-border bg-background/60 flex flex-col shrink-0">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Buscar colaborador..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 text-xs h-8 bg-card border-slate-300 dark:border-border text-foreground font-medium"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border/40 p-1.5 space-y-0.5">
{loading ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Carregando colaboradores...
</div>
) : usersFiltrados.length === 0 ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Nenhum colaborador encontrado.
</div>
) : (
usersFiltrados.map((user) => {
const isSelected = user.userId === selectedUserId;
return (
<button
key={user.userId}
onClick={() => setSelectedUserId(user.userId)}
className={`w-full text-left p-2.5 rounded-xl transition-all flex items-center gap-2.5 ${
isSelected
? 'bg-emerald-500/15 border border-emerald-500/40 text-foreground shadow-sm'
: 'hover:bg-muted/60 text-muted-foreground hover:text-foreground'
}`}
>
<Avatar className="h-8 w-8 rounded-lg border border-border">
<AvatarImage src={user.profileImageUrl || ''} />
<AvatarFallback className="rounded-lg bg-primary/10 text-primary text-xs font-bold">
{user.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className={`text-xs font-bold truncate ${isSelected ? 'text-emerald-700 dark:text-emerald-400 font-extrabold' : 'text-slate-800 dark:text-foreground'}`}>
{user.fullName}
</p>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground truncate">
{user.functionName}
</p>
</div>
{user.whatsappNumber && (
<div className="p-1 rounded-md bg-emerald-500/15 text-emerald-700 dark:text-emerald-400" title={`WhatsApp: ${user.whatsappNumber}`}>
<MessageSquare className="h-3 w-3" />
</div>
)}
</button>
);
})
)}
</div>
</div>
{/* ÁREA DE CONFIGURAÇÃO DO USUÁRIO SELECIONADO */}
<div className="flex-1 flex flex-col bg-card overflow-y-auto">
{selectedUser ? (
<div className="p-5 sm:p-6 space-y-6">
{/* CABEÇALHO DO USUÁRIO + PERFIL */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 rounded-2xl border border-border bg-muted/20">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 rounded-xl border border-border shadow-sm">
<AvatarImage src={selectedUser.profileImageUrl || ''} />
<AvatarFallback className="rounded-xl bg-primary/10 text-primary text-sm font-bold">
{selectedUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-base font-bold text-slate-900 dark:text-foreground">
{selectedUser.fullName}
</h3>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span className="text-slate-600 dark:text-slate-400">{selectedUser.email}</span>
<span>•</span>
<span className="text-emerald-600 dark:text-emerald-400 font-bold">{selectedUser.functionName}</span>
</p>
</div>
</div>
{/* BOTÃO PRINCIPAL DE SALVAR ALTERAÇÕES */}
<Button
size="sm"
onClick={() => salvarConfiguracaoUsuario(selectedUser.userId)}
disabled={saving}
className="text-xs h-9 px-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold shadow-sm flex items-center gap-1.5 transition-transform active:scale-95"
>
<Save className={`h-4 w-4 ${saving ? 'animate-spin' : ''}`} />
{saving ? 'Salvando...' : 'Salvar Alterações'}
</Button>
</div>
{/* BARRA DE TEMPLATES RÁPIDOS & CRIAR NOVO TEMPLATE */}
<div className="p-3.5 rounded-2xl border border-border bg-background/50 space-y-2.5">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
<Sparkles className="h-3.5 w-3.5 text-amber-500" />
Templates de Acesso em 1 Clique
</div>
<Button
size="sm"
variant="outline"
onClick={() => setModalCriarTemplateOpen(true)}
className="text-xs h-7 px-2.5 border-emerald-500/40 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 font-bold flex items-center gap-1 shadow-sm"
>
<Plus className="h-3.5 w-3.5" />
Criar Novo Template
</Button>
</div>
{/* CHIPS DE TEMPLATES */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 max-w-full">
{templates.map((tpl) => (
<div
key={tpl.id}
className="group relative shrink-0 flex items-center"
>
<Button
size="sm"
variant="outline"
onClick={() => aplicarTemplate(selectedUser.userId, tpl)}
className="text-xs h-8 pl-2.5 pr-2.5 rounded-xl border-slate-300 dark:border-border hover:bg-muted/80 flex items-center gap-1.5 shadow-xs font-medium"
title={tpl.descricao}
>
{getIconComponent(tpl.icone)}
<span className="font-semibold text-slate-800 dark:text-slate-200">
{tpl.nome}
</span>
{tpl.badge && (
<Badge
variant="secondary"
className="ml-1 text-[9px] px-1 py-0 h-4 bg-muted text-muted-foreground"
>
{tpl.badge}
</Badge>
)}
</Button>
{/* Botão para deletar template customizado */}
{tpl.isCustom && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removerTemplateCustom(tpl.id);
}}
className="ml-1 p-1 text-muted-foreground hover:text-rose-500 rounded-md transition-colors"
title="Remover este template"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
{/* SEÇÃO 1: MATRIZ DE ACESSO AOS SUBMÓDULOS */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
1. Permissões Granulares nos Submódulos (Modo Smart)
</h4>
<span className="text-[11px] text-slate-500 dark:text-muted-foreground font-medium">
Controle de Visualização, Edição/Apontamento e Exclusão
</span>
</div>
<div className="rounded-2xl border border-border bg-background/50 overflow-hidden shadow-xs">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border/80 bg-muted/40 text-[11px] text-slate-600 dark:text-muted-foreground font-bold">
<th className="p-3">Submódulo / Recurso</th>
<th className="p-3 text-center w-[110px]">
<span className="inline-flex items-center gap-1 text-sky-600 dark:text-sky-400 font-bold">
<Eye className="h-3.5 w-3.5" />
Visualizar
</span>
</th>
<th className="p-3 text-center w-[130px]">
<span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400 font-bold">
<Edit3 className="h-3.5 w-3.5" />
Apontar / Editar
</span>
</th>
<th className="p-3 text-center w-[110px]">
<span className="inline-flex items-center gap-1 text-rose-600 dark:text-rose-400 font-bold">
<Trash2 className="h-3.5 w-3.5" />
Excluir
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40 text-xs">
{SMART_SUBMODULES.map((sub) => {
const perm = selectedUser.permissions[sub.key] || {
can_view: false,
can_edit: false,
can_delete: false,
};
return (
<tr
key={sub.key}
className="hover:bg-muted/30 transition-colors"
>
<td className="p-3">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-card border border-border/60 shadow-xs">
{getIconComponent(sub.icone)}
</div>
<div>
<p className="font-bold text-slate-900 dark:text-foreground text-xs">
{sub.nome}
</p>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
{sub.descricao}
</p>
</div>
</div>
</td>
{/* VISUALIZAR */}
<td className="p-3 text-center">
<div className="flex justify-center">
<Switch
checked={perm.can_view}
onCheckedChange={() =>
togglePermission(selectedUser.userId, sub.key, 'can_view')
}
/>
</div>
</td>
{/* EDITAR / APONTAR */}
<td className="p-3 text-center">
<div className="flex justify-center">
<Switch
checked={perm.can_edit}
onCheckedChange={() =>
togglePermission(selectedUser.userId, sub.key, 'can_edit')
}
/>
</div>
</td>
{/* EXCLUIR */}
<td className="p-3 text-center">
<div className="flex justify-center">
<Switch
checked={perm.can_delete}
onCheckedChange={() =>
togglePermission(selectedUser.userId, sub.key, 'can_delete')
}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* SEÇÃO 2: NOTIFICAÇÕES & CANAL WHATSAPP VIA ÚNICA */}
<div className="space-y-4 pt-2">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
2. Notificações do Usuário (E-mail & WhatsApp BrainSteel)
</h4>
{/* BANNER INFORMATIVO: ALTO CONTRASTE NO MODO CLARO E ESCURO */}
<div className="p-4 rounded-2xl border border-amber-400/80 dark:border-amber-500/30 bg-amber-100/80 dark:bg-amber-500/10 text-amber-950 dark:text-amber-200 flex items-start gap-3 shadow-xs">
<AlertTriangle className="h-5 w-5 text-amber-700 dark:text-amber-400 shrink-0 mt-0.5" />
<div className="text-xs space-y-1">
<p className="font-extrabold text-amber-900 dark:text-amber-300 text-xs">
Comunicação Estritamente Unidirecional (Via Única) no WhatsApp
</p>
<p className="text-amber-950 dark:text-amber-200/90 leading-relaxed text-[11px] font-medium">
O número oficial da BrainSteel é configurado para <strong>somente enviar</strong> alertas, relatórios e métricas de produção. Mensagens de resposta enviadas pelo colaborador não serão recebidas nem respondidas pelo sistema.
</p>
</div>
</div>
{/* GRID DE CONFIGURAÇÕES DE CANAL */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* CANAL WHATSAPP */}
<div className="p-4 rounded-2xl border border-border bg-background/50 space-y-4 shadow-xs">
<div className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400 font-bold text-xs">
<Phone className="h-4 w-4" />
Canal WhatsApp do Colaborador
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-semibold text-slate-700 dark:text-slate-300">
Número de WhatsApp com DDD
</Label>
<Input
placeholder="Ex: 5511999998888 ou (11) 99999-8888"
value={selectedUser.whatsappNumber}
onChange={(e) =>
updateNotificationConfig(
selectedUser.userId,
'whatsappNumber',
e.target.value
)
}
className="h-8 text-xs bg-background dark:bg-card border-slate-300 dark:border-border text-foreground font-medium placeholder:text-slate-500 dark:placeholder:text-muted-foreground/80"
/>
<span className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium block">
Informe o número com DDD para receber avisos automáticos.
</span>
</div>
<div className="pt-2 border-t border-border/40 space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Alertas de Apontamentos
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Notificar sobre novos apontamentos ou avanços forçados
</p>
</div>
<Switch
checked={selectedUser.notifWhatsappApontamentos}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifWhatsappApontamentos',
val
)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Resumo Diário de Métricas
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Receber fechamento diário de produção (peso e peças)
</p>
</div>
<Switch
checked={selectedUser.notifWhatsappMetricas}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifWhatsappMetricas',
val
)
}
/>
</div>
</div>
</div>
{/* CANAL E-MAIL */}
<div className="p-4 rounded-2xl border border-border bg-background/50 space-y-4 shadow-xs">
<div className="flex items-center gap-2 text-sky-600 dark:text-sky-400 font-bold text-xs">
<Mail className="h-4 w-4" />
Canal de E-mail ({selectedUser.email})
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Alertas de Apontamentos por E-mail
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Receber e-mail informativo a cada lote apontado
</p>
</div>
<Switch
checked={selectedUser.notifEmailApontamentos}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifEmailApontamentos',
val
)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Relatórios e Métricas por E-mail
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Receber resumos periódicos e PDFs automáticos
</p>
</div>
<Switch
checked={selectedUser.notifEmailMetricas}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifEmailMetricas',
val
)
}
/>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Selecione um colaborador na barra lateral para configurar.
</div>
)}
</div>
</div>
{/* RODAPÉ DO MODAL */}
<div className="px-6 py-3 border-t border-border bg-muted/40 flex items-center justify-between text-xs">
<span className="text-muted-foreground text-[11px]">
As alterações passam a valer imediatamente no Modo Smart após salvar.
</span>
<Button
variant="outline"
size="sm"
onClick={onClose}
className="text-xs h-8 font-medium"
>
Fechar
</Button>
</div>
</DialogContent>
{/* MODAL PARA SALVAR NOVO TEMPLATE PERSONALIZADO */}
{selectedUser && (
<CriarSmartTemplateModal
isOpen={modalCriarTemplateOpen}
onClose={() => setModalCriarTemplateOpen(false)}
currentPermissions={selectedUser.permissions}
onSalvarTemplate={criarNovoTemplate}
/>
)}
</Dialog>
);
};
@@ -0,0 +1,923 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three-stdlib';
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
import { DefectItem, ColorizationMode } from '@/types/analisador3d';
import { ViewCube } from './ViewCube';
import {
RotateCcw,
RotateCw,
Lock,
Unlock,
Eye,
Maximize2,
Minimize2,
Box,
Layers,
Sparkles,
Search,
Crosshair,
AlertTriangle,
CheckCircle,
HelpCircle,
Cpu,
Focus,
SunMedium,
Compass
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
interface AnalyzerViewer3DProps {
modelData: LoadedIFCResult | null;
defects: DefectItem[];
selectedDefect: DefectItem | null;
selectedPieceMark: string | null;
colorizationMode: ColorizationMode;
onSelectDefect?: (defect: DefectItem | null) => void;
onSelectPiece?: (pieceMark: string | null) => void;
isGhostMode?: boolean;
onToggleGhostMode?: () => void;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
theme?: 'dark' | 'light';
selectedOF?: string;
initialRotation?: { x: number; y: number; z: number } | null;
}
export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
modelData,
defects,
selectedDefect,
selectedPieceMark,
colorizationMode,
onSelectDefect,
onSelectPiece,
isGhostMode = false,
onToggleGhostMode,
isFullscreen = false,
onToggleFullscreen,
theme = 'dark',
selectedOF,
initialRotation,
}) => {
const { toast } = useToast();
const [rotationX, setRotationX] = useState<number>(0);
const [rotationY, setRotationY] = useState<number>(0);
const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.Camera | null>(null);
const perspCameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const orthoCameraRef = useRef<THREE.OrthographicCamera | null>(null);
const [isOrthographic, setIsOrthographic] = useState<boolean>(false);
const controlsRef = useRef<OrbitControls | null>(null);
const modelGroupRef = useRef<THREE.Group | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null);
// Iluminação ajustável em tempo real
const ambientLightRef = useRef<THREE.AmbientLight | null>(null);
const hemiLightRef = useRef<THREE.HemisphereLight | null>(null);
const dirLight1Ref = useRef<THREE.DirectionalLight | null>(null);
const dirLight2Ref = useRef<THREE.DirectionalLight | null>(null);
const [brightness, setBrightness] = useState<number>(100);
const [wireframe, setWireframe] = useState<boolean>(false);
const [hoveredDefect, setHoveredDefect] = useState<DefectItem | null>(null);
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null);
// Mapeamentos rápidos para busca e colorização
const defectByPieceMap = useRef<Map<string, DefectItem>>(new Map());
useEffect(() => {
const map = new Map<string, DefectItem>();
defects.forEach((d) => {
map.set(d.pecaMarca.toUpperCase(), d);
if (d.pecaConectadaMarca) {
map.set(d.pecaConectadaMarca.toUpperCase(), d);
}
});
defectByPieceMap.current = map;
}, [defects]);
// 1. Inicializa o Three.js
useEffect(() => {
if (!canvasContainerRef.current) return;
const width = canvasContainerRef.current.clientWidth || 800;
const height = canvasContainerRef.current.clientHeight || 500;
const scene = new THREE.Scene();
sceneRef.current = scene;
const perspCamera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
perspCamera.position.set(40, 35, 50);
perspCameraRef.current = perspCamera;
const frustumSize = 80;
const aspect = width / height;
const orthoCamera = new THREE.OrthographicCamera(
(frustumSize * aspect) / -2,
(frustumSize * aspect) / 2,
frustumSize / 2,
frustumSize / -2,
0.1,
2000
);
orthoCamera.position.copy(perspCamera.position);
orthoCameraRef.current = orthoCamera;
cameraRef.current = perspCamera;
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance',
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
rendererRef.current = renderer;
canvasContainerRef.current.innerHTML = '';
canvasContainerRef.current.appendChild(renderer.domElement);
const controls = new OrbitControls(perspCamera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.maxDistance = 1500;
controls.minDistance = 0.2;
controlsRef.current = controls;
// Iluminação balanceada industrial com suporte a controle de luminosidade
const ambientLight = new THREE.AmbientLight(0xffffff, 0.85);
scene.add(ambientLight);
ambientLightRef.current = ambientLight;
// Luz hemisférica uniforme: ilumina simultaneamente de cima e baixo para eliminar sombras duras de normais invertidas
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x475569, 0.6);
scene.add(hemiLight);
hemiLightRef.current = hemiLight;
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight1.position.set(80, 120, 80);
scene.add(dirLight1);
dirLight1Ref.current = dirLight1;
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6);
dirLight2.position.set(-80, -40, -80);
scene.add(dirLight2);
dirLight2Ref.current = dirLight2;
// Grid no piso com cores refinadas para modo claro e escuro
const isDarkMode = document.documentElement.classList.contains('dark');
const gridColor1 = isDarkMode ? 0x475569 : 0x94a3b8;
const gridColor2 = isDarkMode ? 0x1e293b : 0xe2e8f0;
const grid = new THREE.GridHelper(100, 50, gridColor1, gridColor2);
grid.position.y = -0.01;
if (grid.material && (grid.material as THREE.Material)) {
(grid.material as THREE.Material).transparent = true;
(grid.material as THREE.Material).opacity = 0.55;
}
scene.add(grid);
gridRef.current = grid;
// Observador para atualizar cores do grid automaticamente quando o usuário alternar tema
const themeObserver = new MutationObserver(() => {
const isDarkNow = document.documentElement.classList.contains('dark');
const centerC = isDarkNow ? new THREE.Color(0x475569) : new THREE.Color(0x94a3b8);
const lineC = isDarkNow ? new THREE.Color(0x1e293b) : new THREE.Color(0xe2e8f0);
if (gridRef.current) {
const colors = gridRef.current.geometry.attributes.color as THREE.BufferAttribute;
if (colors) {
const arr = colors.array as Float32Array;
for (let i = 0; i < arr.length; i += 6) {
const isCenter = i === Math.floor(arr.length / 2);
const c = isCenter ? centerC : lineC;
arr[i] = c.r;
arr[i + 1] = c.g;
arr[i + 2] = c.b;
arr[i + 3] = c.r;
arr[i + 4] = c.g;
arr[i + 5] = c.b;
}
colors.needsUpdate = true;
}
}
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
let animId: number;
const animate = () => {
animId = requestAnimationFrame(animate);
controls.update();
if (renderer && scene && cameraRef.current) {
renderer.render(scene, cameraRef.current);
}
};
animate();
const handleResize = () => {
if (!canvasContainerRef.current || !renderer) return;
const w = canvasContainerRef.current.clientWidth;
const h = canvasContainerRef.current.clientHeight;
if (w <= 0 || h <= 0) return;
const newAspect = w / h;
if (perspCameraRef.current) {
perspCameraRef.current.aspect = newAspect;
perspCameraRef.current.updateProjectionMatrix();
}
if (orthoCameraRef.current) {
const frustum = 80;
orthoCameraRef.current.left = (frustum * newAspect) / -2;
orthoCameraRef.current.right = (frustum * newAspect) / 2;
orthoCameraRef.current.top = frustum / 2;
orthoCameraRef.current.bottom = frustum / -2;
orthoCameraRef.current.updateProjectionMatrix();
}
renderer.setSize(w, h);
};
window.addEventListener('resize', handleResize);
const observer = new ResizeObserver(handleResize);
observer.observe(canvasContainerRef.current);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener('resize', handleResize);
observer.disconnect();
themeObserver.disconnect();
renderer.dispose();
controls.dispose();
};
}, [theme]);
// Efeito dinâmico de ajuste de Luminosidade / Iluminação
useEffect(() => {
const factor = brightness / 100;
if (ambientLightRef.current) {
ambientLightRef.current.intensity = 0.85 * factor;
}
if (hemiLightRef.current) {
hemiLightRef.current.intensity = 0.6 * factor;
}
if (dirLight1Ref.current) {
dirLight1Ref.current.intensity = 1.2 * Math.min(1.4, factor);
}
if (dirLight2Ref.current) {
dirLight2Ref.current.intensity = 0.6 * factor;
}
if (rendererRef.current) {
rendererRef.current.toneMappingExposure = 1.1 * (0.85 + 0.15 * factor);
}
}, [brightness]);
// Alterna entre Câmera Perspectiva e Ortográfica
const handleToggleCamera = useCallback(() => {
if (!perspCameraRef.current || !orthoCameraRef.current || !controlsRef.current) return;
const nextOrtho = !isOrthographic;
setIsOrthographic(nextOrtho);
const prevCamera = cameraRef.current!;
const nextCamera = nextOrtho ? orthoCameraRef.current : perspCameraRef.current;
nextCamera.position.copy(prevCamera.position);
nextCamera.quaternion.copy(prevCamera.quaternion);
cameraRef.current = nextCamera;
controlsRef.current.object = nextCamera;
controlsRef.current.update();
}, [isOrthographic]);
// Centraliza e enquadra perfeitamente o modelo na câmera (Fit View / Fit to Screen)
const fitModelToView = useCallback((targetObj?: THREE.Object3D) => {
const obj = targetObj || modelGroupRef.current;
if (!obj || !cameraRef.current || !controlsRef.current) return;
obj.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(obj);
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim <= 0) return;
const fov = 45 * (Math.PI / 180);
let cameraDistance = Math.abs(maxDim / (2 * Math.tan(fov / 2))) * 1.6;
cameraDistance = Math.max(cameraDistance, 5);
const camera = cameraRef.current;
if ((camera as THREE.PerspectiveCamera).isPerspectiveCamera) {
const pCam = camera as THREE.PerspectiveCamera;
pCam.near = Math.max(0.01, maxDim / 500);
pCam.far = Math.max(2000, maxDim * 20);
pCam.updateProjectionMatrix();
pCam.position.set(center.x + cameraDistance * 0.7, center.y + cameraDistance * 0.5, center.z + cameraDistance * 0.7);
pCam.lookAt(center);
} else if ((camera as THREE.OrthographicCamera).isOrthographicCamera) {
const oCam = camera as THREE.OrthographicCamera;
const aspect = oCam.right / oCam.top;
const frustum = maxDim * 1.5;
oCam.left = (frustum * aspect) / -2;
oCam.right = (frustum * aspect) / 2;
oCam.top = frustum / 2;
oCam.bottom = frustum / -2;
oCam.near = Math.max(0.01, maxDim / 500);
oCam.far = Math.max(2000, maxDim * 20);
oCam.position.set(center.x + maxDim, center.y + maxDim * 0.8, center.z + maxDim);
oCam.lookAt(center);
oCam.updateProjectionMatrix();
}
controlsRef.current.target.copy(center);
controlsRef.current.maxDistance = Math.max(1500, maxDim * 10);
controlsRef.current.update();
if (gridRef.current) {
gridRef.current.position.set(center.x, box.min.y, center.z);
}
}, []);
// 2. Carrega o modelo na cena diretamente sem deep clone circular e aplica rotação
useEffect(() => {
const scene = sceneRef.current;
if (!scene) return;
if (modelGroupRef.current) {
scene.remove(modelGroupRef.current);
modelGroupRef.current = null;
}
if (!modelData || !modelData.sceneGroup) return;
// Usa diretamente a sceneGroup da carga sem clone para evitar erro de circular JSON
const group = modelData.sceneGroup;
modelGroupRef.current = group;
scene.add(group);
// Recupera rotação memorizada para esta OF (localStorage ou banco de dados)
const storageKey = selectedOF ? `tracksteel_model_rot_${selectedOF}` : null;
let savedX = 0;
let savedY = 0;
let isLocked = false;
if (storageKey) {
const stored = localStorage.getItem(storageKey);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (typeof parsed.x === 'number') savedX = parsed.x;
if (typeof parsed.y === 'number') savedY = parsed.y;
isLocked = true;
} catch (e) {}
}
}
if (!isLocked && initialRotation) {
if (typeof initialRotation.x === 'number') savedX = initialRotation.x;
if (typeof initialRotation.y === 'number') savedY = initialRotation.y;
isLocked = true;
}
setRotationX(savedX);
setRotationY(savedY);
setIsRotationLocked(isLocked);
// Aplica rotação inicial no grupo do modelo
group.rotation.set(
THREE.MathUtils.degToRad(savedX),
THREE.MathUtils.degToRad(savedY),
0
);
group.updateMatrixWorld(true);
fitModelToView(group);
}, [modelData, selectedOF, initialRotation, fitModelToView]);
// Gira o modelo em 90 graus no eixo X (tombamento vertical para alinhar Z-Up do CAD com Y-Up)
const handleRotate90X = useCallback(() => {
if (!modelGroupRef.current) return;
const nextX = (rotationX + 90) % 360;
setRotationX(nextX);
setIsRotationLocked(false);
modelGroupRef.current.rotation.x = THREE.MathUtils.degToRad(nextX);
modelGroupRef.current.rotation.y = THREE.MathUtils.degToRad(rotationY);
modelGroupRef.current.updateMatrixWorld(true);
fitModelToView(modelGroupRef.current);
}, [rotationX, rotationY, fitModelToView]);
// Alterna o bloqueio/gravação da posição ideal de visualização no banco e no navegador
const handleToggleLockRotation = useCallback(async () => {
if (!selectedOF) {
toast({
title: 'Aviso',
description: 'Selecione uma OF para memorizar a rotação do modelo.',
});
return;
}
if (!isRotationLocked) {
// Trava e grava a rotação
setIsRotationLocked(true);
const rotData = { x: rotationX, y: rotationY, z: 0 };
// 1. Grava no localStorage para acesso instantâneo
localStorage.setItem(`tracksteel_model_rot_${selectedOF}`, JSON.stringify(rotData));
// 2. Persiste na nuvem/Supabase na tabela ordens_fabricacao
try {
await supabase
.from('ordens_fabricacao' as any)
.update({ model_3d_rotation: rotData })
.eq('num_of', selectedOF);
} catch (err) {
console.error('Erro ao salvar rotação no banco:', err);
}
toast({
title: 'Posição Travada e Salva',
description: `Posição ideal de visualização (${rotationX}°) gravada com sucesso para a OF ${selectedOF}.`,
});
} else {
// Destrava para permitir novo ajuste
setIsRotationLocked(false);
toast({
title: 'Posição Destravada',
description: 'Clique em "Girar 90°" para ajustar e trave no cadeado novamente quando estiver na posição correta.',
});
}
}, [selectedOF, isRotationLocked, rotationX, rotationY, toast]);
// 3. Aplica Colorização Dinâmica com base no modo selecionado e na peça/defeito ativo
useEffect(() => {
if (!modelGroupRef.current) return;
const group = modelGroupRef.current;
const targetMark = selectedPieceMark?.toUpperCase() || selectedDefect?.pecaMarca.toUpperCase();
group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
// Tenta achar a marca associada na userData
const pieceInfo = obj.userData?.pieceInfo as PieceInfo | undefined;
const mark = (pieceInfo?.pieceMark || obj.userData?.pieceMark || '').toUpperCase();
const defect = defectByPieceMap.current.get(mark);
const isSelected = targetMark && (mark === targetMark || (defect && defect.pecaConectadaMarca?.toUpperCase() === targetMark));
let color = '#94a3b8'; // neutro default
let emissiveColor = '#000000';
let emissiveIntensity = 0;
if (colorizationMode === 'status_defeito') {
if (defect) {
if (defect.gravidade === 'critico') color = '#ef4444'; // vermelho
else if (defect.gravidade === 'alerta') color = '#f59e0b'; // laranja/amarelo
else color = '#3b82f6'; // azul
} else {
color = '#10b981'; // verde conforme
}
} else if (colorizationMode === 'diff_revisao') {
if (defect?.tipo === 'peca_nova') color = '#3b82f6'; // azul novo
else if (defect?.tipo === 'peca_excluida') color = '#ef4444'; // vermelho excluído
else if (defect?.tipo === 'peca_modificada' || defect?.tipo === 'deslocamento_furo' || defect?.tipo === 'furo_extra_revisao') {
color = '#f59e0b'; // amarelo modificado
} else {
color = '#10b981'; // verde inalterado
}
} else if (colorizationMode === 'risco_fabril') {
if (defect) {
if (defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto') color = '#dc2626';
else if (defect.riscoFabril === 'medio') color = '#f59e0b';
else color = '#10b981';
} else {
color = '#64748b';
}
} else if (colorizationMode === 'processo') {
color = defect?.processoCor || '#3b82f6';
}
// Se estiver em modo fantasma e houver seleção: atenua peças não selecionadas
let opacity = 1.0;
let transparent = false;
if (isGhostMode && targetMark) {
if (!isSelected) {
opacity = 0.12;
transparent = true;
} else {
opacity = 1.0;
emissiveColor = color;
emissiveIntensity = 0.5;
}
} else if (isSelected) {
emissiveColor = '#ffffff';
emissiveIntensity = 0.35;
}
// Aramado Arquitetural Limpo (EdgesGeometry - Estilo SteelXR / Tekla sem diagonais internas)
if (wireframe) {
// Cria as arestas de contorno se ainda não existirem para esta malha
if (!obj.userData.edgesLine) {
const edgesGeo = new THREE.EdgesGeometry(obj.geometry, 24);
const lineMat = new THREE.LineBasicMaterial({
color: new THREE.Color(color),
transparent: true,
opacity: isGhostMode && targetMark && !isSelected ? 0.15 : 0.95,
});
const edgesLine = new THREE.LineSegments(edgesGeo, lineMat);
edgesLine.name = 'edgesLine';
obj.userData.edgesLine = edgesLine;
obj.add(edgesLine);
} else {
// Atualiza a cor e opacidade das arestas existentes
const lineMat = obj.userData.edgesLine.material as THREE.LineBasicMaterial;
if (lineMat) {
lineMat.color.set(color);
lineMat.opacity = isGhostMode && targetMark && !isSelected ? 0.15 : 0.95;
}
}
obj.userData.edgesLine.visible = true;
// Oculta a malha sólida para exibir apenas o aramado limpo
obj.material = new THREE.MeshBasicMaterial({
visible: false,
wireframe: false,
});
} else {
// Modo Sólido: desativa as linhas de contorno e restaura o sólido PBR
if (obj.userData.edgesLine) {
obj.userData.edgesLine.visible = false;
}
obj.material = new THREE.MeshStandardMaterial({
color: new THREE.Color(color),
emissive: new THREE.Color(emissiveColor),
emissiveIntensity,
roughness: 0.35,
metalness: 0.2,
wireframe: false,
transparent,
opacity,
side: THREE.DoubleSide,
});
}
}
});
}, [colorizationMode, selectedDefect, selectedPieceMark, isGhostMode, wireframe, modelData]);
// 4. Foco na Câmera ao selecionar defeito/peça
const focusOnTarget = useCallback((coords?: { x: number; y: number; z: number }) => {
if (!coords || !cameraRef.current || !controlsRef.current) return;
const target = new THREE.Vector3(coords.x, coords.y, coords.z);
controlsRef.current.target.copy(target);
cameraRef.current.position.set(target.x + 3, target.y + 3, target.z + 4);
controlsRef.current.update();
}, []);
useEffect(() => {
if (selectedDefect && selectedDefect.coordenadas3D) {
focusOnTarget(selectedDefect.coordenadas3D);
}
}, [selectedDefect, focusOnTarget]);
// 5. Clique no 3D (Raycasting)
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!canvasContainerRef.current || !cameraRef.current || !modelGroupRef.current) return;
const rect = canvasContainerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(x, y), cameraRef.current);
const intersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
if (intersects.length > 0) {
const hit = intersects[0].object as THREE.Mesh;
const pieceInfo = hit.userData?.pieceInfo as PieceInfo | undefined;
const mark = pieceInfo?.pieceMark || hit.userData?.pieceMark;
if (mark) {
onSelectPiece?.(mark);
const def = defectByPieceMap.current.get(mark.toUpperCase());
onSelectDefect?.(def || null);
}
}
};
// Reset de Câmera
const handleResetCamera = () => {
if (!modelGroupRef.current || !cameraRef.current || !controlsRef.current) return;
const bbox = new THREE.Box3().setFromObject(modelGroupRef.current);
const size = new THREE.Vector3();
bbox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
cameraRef.current.position.set(maxDim * 1.3, maxDim * 1.1, maxDim * 1.5);
controlsRef.current.target.set(0, size.y / 2, 0);
controlsRef.current.update();
};
// ViewCube Switcher de Direções
const handleViewCubeSelect = useCallback(
(view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => {
if (!controlsRef.current || !cameraRef.current) return;
const target = controlsRef.current.target;
const camera = cameraRef.current;
const distance = camera.position.distanceTo(target);
const newPos = target.clone();
switch (view) {
case 'top':
newPos.y += distance;
break;
case 'bottom':
newPos.y -= distance;
break;
case 'front':
newPos.z += distance;
break;
case 'back':
newPos.z -= distance;
break;
case 'left':
newPos.x -= distance;
break;
case 'right':
newPos.x += distance;
break;
case 'iso':
newPos.x += distance * 0.7;
newPos.y += distance * 0.7;
newPos.z += distance * 0.7;
break;
}
camera.position.copy(newPos);
controlsRef.current.update();
},
[]
);
return (
<div
ref={containerRef}
className="relative w-full h-full select-none overflow-hidden rounded-xl border bg-slate-50 dark:bg-slate-950 border-slate-200 dark:border-slate-800 shadow-sm"
>
{/* Container Three.js */}
<div
ref={canvasContainerRef}
className="w-full h-full cursor-grab active:cursor-grabbing"
onPointerDown={handlePointerDown}
/>
{/* Top Right: ViewCube + Widget de Rotação 90° & Cadeado de Memória */}
<div className="absolute top-3 right-3 z-20 flex flex-col items-end gap-2.5 pointer-events-auto">
{cameraRef.current && controlsRef.current && (
<ViewCube
onSelectView={handleViewCubeSelect}
mainCameraRef={cameraRef as React.MutableRefObject<THREE.Camera | null>}
/>
)}
{/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */}
<div
className="flex items-center gap-1.5 p-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto"
style={{ minWidth: '130px' }}
>
{/* Botão Rotacionar / Tombar 90° */}
<button
onClick={handleRotate90X}
disabled={!modelData}
className={`flex-1 flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[11px] font-bold transition-all duration-150 active:scale-95 ${
modelData
? 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-100 cursor-pointer shadow-sm'
: 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50'
}`}
title="Girar o modelo 90° no eixo de tombamento (adequar orientação Z/Y)"
>
<RotateCw className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
<span className="whitespace-nowrap">Girar 90°</span>
<span className="text-[10px] font-mono font-normal text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-950 px-1 py-0.2 rounded border border-slate-200 dark:border-slate-800">
{rotationX}°
</span>
</button>
{/* Botão Cadeado: Travar / Gravar ou Destravar */}
<button
onClick={handleToggleLockRotation}
disabled={!modelData}
className={`flex items-center justify-center p-1.5 rounded-xl transition-all duration-200 active:scale-95 border ${
!modelData
? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40'
: isRotationLocked
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/60 dark:hover:bg-emerald-900/80 dark:text-emerald-300 dark:border-emerald-700/80 shadow-sm cursor-pointer'
: 'bg-amber-50 hover:bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/50 dark:hover:bg-amber-900/70 dark:text-amber-300 dark:border-amber-700/70 shadow-sm cursor-pointer animate-pulse'
}`}
title={
isRotationLocked
? 'Posição TRAVADA e salva para esta OF. Clique para destravar e ajustar.'
: 'Posição DESTRAVADA. Clique aqui no cadeado para travar e salvar a posição ideal.'
}
>
{isRotationLocked ? (
<Lock className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
) : (
<Unlock className="w-4 h-4 text-amber-600 dark:text-amber-400" />
)}
</button>
</div>
</div>
{/* Barra de Ferramentas Flutuante Superior */}
<div className="absolute top-3 left-3 flex flex-wrap items-center gap-1.5 p-1.5 bg-white/95 dark:bg-slate-900/90 backdrop-blur-md border border-slate-200 dark:border-slate-700/80 rounded-xl shadow-md z-20">
{/* Reset Câmera */}
<button
onClick={handleResetCamera}
title="Resetar Câmera"
className="p-1.5 text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-300 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition"
>
<RotateCcw className="w-4 h-4" />
</button>
{/* Centralizar e Enquadrar (Fit to Screen) */}
<button
onClick={() => fitModelToView()}
title="Centralizar e Enquadrar Estrutura na Tela (Fit to Screen)"
className="p-1.5 text-slate-600 hover:text-cyan-600 hover:bg-cyan-50 dark:text-slate-300 dark:hover:text-cyan-400 dark:hover:bg-cyan-950/40 rounded-lg transition"
>
<Focus className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
</button>
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-700 mx-0.5" />
{/* Comutar Perspectiva / Ortogonal */}
<button
onClick={handleToggleCamera}
title={isOrthographic ? 'Mudar para Vista em Perspectiva (3D Cônica)' : 'Mudar para Vista Ortogonal (Paralela Técnica)'}
className={`flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded-lg transition ${
isOrthographic
? 'bg-emerald-600 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`}
>
<Compass className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span>
</button>
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-700 mx-0.5" />
{/* Modo Raio-X / Foco */}
<button
onClick={onToggleGhostMode}
title={isGhostMode ? 'Desativar Modo Raio-X / Foco' : 'Ativar Modo Raio-X / Foco'}
className={`flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded-lg transition ${
isGhostMode
? 'bg-indigo-600 text-white shadow'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`}
>
<Eye className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Foco / Raio-X</span>
</button>
{/* Aramado Limpo (EdgesGeometry) */}
<button
onClick={() => setWireframe(!wireframe)}
title={wireframe ? 'Mudar para Modelo Sólido' : 'Mudar para Aramado Limpo de Engenharia (sem linhas internas)'}
className={`p-1.5 rounded-lg transition ${
wireframe
? 'bg-indigo-600 text-white shadow-xs'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`}
>
{wireframe ? <Layers className="w-4 h-4" /> : <Box className="w-4 h-4" />}
</button>
{onToggleFullscreen && (
<button
onClick={onToggleFullscreen}
title={isFullscreen ? 'Sair da Tela Cheia' : 'Tela Cheia'}
className="p-1.5 text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-300 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition"
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-700 mx-0.5" />
{/* Barra de Luminosidade (Slider Interativo) */}
<div
className="flex items-center gap-1.5 px-2 py-1 bg-slate-100/90 dark:bg-slate-800/90 rounded-lg border border-slate-200 dark:border-slate-700"
title="Ajuste de Luminosidade da Estrutura (arraste para clarear ou suavizar sombras das peças)"
>
<button
type="button"
onClick={() => setBrightness(100)}
title="Clique para resetar luminosidade em 100%"
className="hover:scale-110 active:scale-95 transition-transform"
>
<SunMedium className="w-3.5 h-3.5 text-amber-500 shrink-0" />
</button>
<input
type="range"
min="40"
max="220"
step="5"
value={brightness}
onChange={(e) => setBrightness(Number(e.target.value))}
className="w-16 sm:w-20 h-1.5 bg-slate-300 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
/>
<span className="text-[10px] font-mono font-bold text-slate-600 dark:text-slate-300 min-w-[28px] text-right">
{brightness}%
</span>
</div>
</div>
{/* Legenda de Cores Flutuante Inferior */}
<div className="absolute bottom-3 left-3 flex flex-wrap items-center gap-3 px-3 py-2 bg-white/95 dark:bg-slate-900/90 backdrop-blur-md border border-slate-200 dark:border-slate-700/80 rounded-lg shadow-md text-xs z-20">
<span className="text-slate-500 dark:text-slate-400 font-medium">Legenda:</span>
{colorizationMode === 'status_defeito' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-sm" />
<span className="text-slate-700 dark:text-slate-300">Conforme</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 shadow-sm" />
<span className="text-slate-700 dark:text-slate-300">Alerta (Deslocado)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 shadow-sm" />
<span className="text-slate-700 dark:text-slate-300">Crítico (Furo Cego/Colisão)</span>
</div>
</>
)}
{colorizationMode === 'diff_revisao' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<span className="text-slate-700 dark:text-slate-300">Inalterada</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<span className="text-slate-700 dark:text-slate-300">Modificada</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<span className="text-slate-700 dark:text-slate-300">Nova</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
<span className="text-slate-700 dark:text-slate-300">Excluída</span>
</div>
</>
)}
{colorizationMode === 'risco_fabril' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<span className="text-slate-700 dark:text-slate-300">Baixo Risco (Sem Corte)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<span className="text-slate-700 dark:text-slate-300">Médio (Em Corte)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-600 animate-pulse" />
<span className="text-slate-700 dark:text-slate-300">Crítico (Soldada/Expedida)</span>
</div>
</>
)}
</div>
{/* Alerta de elemento selecionado flutuante */}
{selectedPieceMark && (
<div className="absolute top-3 left-[225px] px-3 py-1.5 bg-indigo-50 dark:bg-indigo-950/90 border border-indigo-200 dark:border-indigo-700/80 rounded-lg shadow-md text-xs flex items-center gap-2 z-20">
<Crosshair className="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400 animate-spin" style={{ animationDuration: '6s' }} />
<span className="text-indigo-900 dark:text-indigo-200">
Foco Ativo: <strong className="text-indigo-700 dark:text-white font-mono">{selectedPieceMark}</strong>
</span>
<button
onClick={() => {
onSelectPiece?.(null);
onSelectDefect?.(null);
}}
className="ml-1 text-indigo-500 hover:text-indigo-800 dark:hover:text-white"
title="Limpar seleção"
>
×
</button>
</div>
)}
</div>
);
};
+364 -77
View File
@@ -6,7 +6,7 @@ import { ViewCube } from './ViewCube';
import { Viewer3DSidebar } from './Viewer3DSidebar'; import { Viewer3DSidebar } from './Viewer3DSidebar';
import { PieceHoverTooltip } from './PieceHoverTooltip'; import { PieceHoverTooltip } from './PieceHoverTooltip';
import { SelectionListModal, SelectedPieceItem } from './SelectionListModal'; import { SelectionListModal, SelectedPieceItem } from './SelectionListModal';
import { Trash2, FileSpreadsheet, RotateCcw, RotateCw, Lock, Unlock } from 'lucide-react'; import { Trash2, FileSpreadsheet, RotateCcw, RotateCw, Lock, Unlock, Focus, SunMedium, Compass } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
@@ -18,6 +18,91 @@ interface ProductionPieceStatus {
currentProcessName?: string; currentProcessName?: string;
processColor?: string; processColor?: string;
processOrdem?: number; processOrdem?: number;
perfilPrincipal?: string;
descricao?: string;
}
/**
* 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;
}
/**
* Helper para formatar a Marca Principal da Peça no padrão de Engenharia (OF-Fase-Marca, ex: B138-2-1 / B135-2-8)
* e obter o Perfil/Bitola oficial a partir do banco de dados ou do modelo 3D.
*/
function resolvePieceEngineeringDetails(
rawPieceMark: string,
rawSection: string | undefined,
phase: string | undefined,
singlePartMark: string | undefined,
prod: ProductionPieceStatus | undefined,
selectedOF: string | undefined
): {
displayMark: string;
displaySection: string;
} {
const baseMark = prod?.marca || rawPieceMark || 'Peça';
let displayMark = baseMark;
// Se tivermos OF e Fase e a marca for pura (ex: "1" ou "8"), formata como "B138-2-1" idêntico ao Print 2 "B135-2-8"
if (selectedOF && phase && !baseMark.toUpperCase().startsWith(selectedOF.toUpperCase())) {
displayMark = `${selectedOF}-${phase}-${baseMark}`;
}
// Perfil / Bitola oficial: prioriza banco de produção (TS_ERP.pecas.perfil_principal), seguido de section limpo do modelo
let displaySection = prod?.perfilPrincipal || '';
if (!displaySection || displaySection === '1001' || displaySection === 'Perfil') {
if (rawSection && rawSection !== '1001' && rawSection !== 'Perfil') {
displaySection = rawSection;
} else {
displaySection = prod?.descricao || 'Perfil';
}
}
return { displayMark, displaySection };
} }
interface ModelViewer3DProps { interface ModelViewer3DProps {
@@ -59,6 +144,13 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const [colorMode, setColorMode] = useState<'description' | 'production'>('production'); const [colorMode, setColorMode] = useState<'description' | 'production'>('production');
const [selectedProcess, setSelectedProcess] = useState<string>('all'); const [selectedProcess, setSelectedProcess] = useState<string>('all');
// Iluminação ajustável em tempo real
const ambientLightRef = useRef<THREE.AmbientLight | null>(null);
const hemiLightRef = useRef<THREE.HemisphereLight | null>(null);
const dirLight1Ref = useRef<THREE.DirectionalLight | null>(null);
const dirLight2Ref = useRef<THREE.DirectionalLight | null>(null);
const [brightness, setBrightness] = useState<number>(100);
// Hover Tooltip States (1.0 second delay) // Hover Tooltip States (1.0 second delay)
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null); const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
const [hoveredPiece, setHoveredPiece] = useState<PieceInfo | null>(null); const [hoveredPiece, setHoveredPiece] = useState<PieceInfo | null>(null);
@@ -161,18 +253,22 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
// 5. Lights // 5. Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75); const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
scene.add(ambientLight); scene.add(ambientLight);
ambientLightRef.current = ambientLight;
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2); const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight1.position.set(100, 200, 100); dirLight1.position.set(100, 200, 100);
dirLight1.castShadow = true; dirLight1.castShadow = true;
scene.add(dirLight1); scene.add(dirLight1);
dirLight1Ref.current = dirLight1;
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6); const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6);
dirLight2.position.set(-100, -50, -100); dirLight2.position.set(-100, -50, -100);
scene.add(dirLight2); scene.add(dirLight2);
dirLight2Ref.current = dirLight2;
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x1e293b, 0.5); const hemiLight = new THREE.HemisphereLight(0xffffff, 0x1e293b, 0.5);
scene.add(hemiLight); scene.add(hemiLight);
hemiLightRef.current = hemiLight;
// 6. Floor Grid (Cores refinadas e ultrafinas para modo claro e escuro) // 6. Floor Grid (Cores refinadas e ultrafinas para modo claro e escuro)
const updateGridTheme = (gridHelper: THREE.GridHelper) => { const updateGridTheme = (gridHelper: THREE.GridHelper) => {
@@ -278,6 +374,16 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false); const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false);
const { toast } = useToast(); const { toast } = useToast();
// Efeito dinâmico de ajuste de Luminosidade / Iluminação no ModelViewer3D
useEffect(() => {
const factor = brightness / 100;
if (ambientLightRef.current) ambientLightRef.current.intensity = 0.75 * factor;
if (hemiLightRef.current) hemiLightRef.current.intensity = 0.5 * factor;
if (dirLight1Ref.current) dirLight1Ref.current.intensity = 1.2 * Math.min(1.4, factor);
if (dirLight2Ref.current) dirLight2Ref.current.intensity = 0.6 * factor;
if (rendererRef.current) rendererRef.current.toneMappingExposure = 1.0 * (0.85 + 0.15 * factor);
}, [brightness]);
// Function: Fit Model to View // Function: Fit Model to View
const fitModelToView = useCallback((group?: THREE.Group | null) => { const fitModelToView = useCallback((group?: THREE.Group | null) => {
const targetGroup = group || modelGroupRef.current; const targetGroup = group || modelGroupRef.current;
@@ -700,7 +806,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
} }
// Se a peça física pertencer à seleção permanente, preserva a cor magenta // Se a peça física pertencer à seleção permanente, preserva a cor magenta
const ifcId = mesh.userData.ifcId; const ifcId = mesh.userData.ifcId ?? mesh.userData.expressID;
if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) { if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) {
mesh.userData.originalMaterial = mesh.material; mesh.userData.originalMaterial = mesh.material;
mesh.material = magentaMaterialRef.current; mesh.material = magentaMaterialRef.current;
@@ -868,17 +974,44 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
} }
if (hitMesh) { if (hitMesh) {
const expressID = hitMesh.userData.ifcId; const expressID = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
const pmark = hitMesh.userData.pieceMark; const pmark = hitMesh.userData.pieceMark;
if (modelData && expressID !== undefined) { if (modelData && expressID !== undefined) {
const piece = modelData.pieceByExpressID.get(expressID) || { const rawPiece = modelData.pieceByExpressID.get(expressID);
const rawMark = hitMesh.userData.pieceMark || rawPiece?.pieceMark || '';
const phase = hitMesh.userData.phase || rawPiece?.phase;
const singlePartMark = hitMesh.userData.singlePartMark || rawPiece?.singlePartMark;
const prod = findProductionDataForPiece(productionData, {
pieceMark: rawMark,
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
phase,
}, selectedOF);
const { displayMark, displaySection } = resolvePieceEngineeringDetails(
rawMark,
hitMesh.userData.section || rawPiece?.section,
phase,
singlePartMark,
prod,
selectedOF
);
const piece: PieceInfo = {
expressID, expressID,
guid: '', guid: hitMesh.userData.guid || rawPiece?.guid || '',
name: hitMesh.userData.section || 'Peça', name: hitMesh.userData.name || rawPiece?.name || 'Peça',
type: 'PIECE', type: hitMesh.userData.type || rawPiece?.type || 'PIECE',
pieceMark: pmark, pieceMark: displayMark,
section: hitMesh.userData.section, cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
section: displaySection,
phase,
singlePartMark,
}; };
setHoveredPiece(piece); setHoveredPiece(piece);
setHoverPosition({ x: clientX, y: clientY }); setHoverPosition({ x: clientX, y: clientY });
@@ -935,7 +1068,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
} }
if (hitMesh && hitMesh.visible) { if (hitMesh && hitMesh.visible) {
const targetIfcId = hitMesh.userData.ifcId; const targetIfcId = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
if (targetIfcId === undefined) return; if (targetIfcId === undefined) return;
const nextMap = new Map(selectedElementsMapRef.current); const nextMap = new Map(selectedElementsMapRef.current);
@@ -951,29 +1084,57 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
}); });
nextMap.delete(targetIfcId); nextMap.delete(targetIfcId);
} else { } else {
// Marcar peça física inteira: localiza e pinta todas as sub-malhas do mesmo ifcId // Marcar peça física inteira: localiza e pinta todas as sub-malhas do mesmo targetIfcId
const relatedMeshes: THREE.Mesh[] = []; const relatedMeshes: THREE.Mesh[] = [];
if (modelGroupRef.current) { if (modelGroupRef.current) {
modelGroupRef.current.traverse((child) => { modelGroupRef.current.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.userData.ifcId === targetIfcId) { if ((child as THREE.Mesh).isMesh) {
const m = child as THREE.Mesh; const childId = child.userData.ifcId ?? child.userData.expressID;
m.userData.originalMaterial = m.material; if (childId === targetIfcId) {
m.userData.isMagentaSelected = true; const m = child as THREE.Mesh;
m.material = magentaMaterialRef.current; m.userData.originalMaterial = m.material;
relatedMeshes.push(m); m.userData.isMagentaSelected = true;
m.material = magentaMaterialRef.current;
relatedMeshes.push(m);
}
} }
}); });
} }
const pmark = hitMesh.userData.pieceMark; const rawPiece = modelData?.pieceByExpressID.get(targetIfcId);
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || { const rawMark = hitMesh.userData.pieceMark || rawPiece?.pieceMark || '';
const phase = hitMesh.userData.phase || rawPiece?.phase;
const singlePartMark = hitMesh.userData.singlePartMark || rawPiece?.singlePartMark;
const prod = findProductionDataForPiece(productionData, {
pieceMark: rawMark,
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
phase,
}, selectedOF);
const { displayMark, displaySection } = resolvePieceEngineeringDetails(
rawMark,
hitMesh.userData.section || rawPiece?.section,
phase,
singlePartMark,
prod,
selectedOF
);
const piece: PieceInfo = {
expressID: targetIfcId, expressID: targetIfcId,
guid: '', guid: hitMesh.userData.guid || rawPiece?.guid || '',
name: hitMesh.userData.section || 'Peça', name: hitMesh.userData.name || rawPiece?.name || 'Peça',
type: 'PIECE', type: hitMesh.userData.type || rawPiece?.type || 'PIECE',
pieceMark: pmark, pieceMark: displayMark,
section: hitMesh.userData.section, cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
phase: hitMesh.userData.phase, assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
section: displaySection,
phase,
singlePartMark,
}; };
nextMap.set(targetIfcId, { nextMap.set(targetIfcId, {
@@ -997,8 +1158,32 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1; const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1; const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycasterRef.current.setFromCamera(new THREE.Vector2(mouseX, mouseY), activeCameraRef.current); const raycaster = raycasterRef.current;
const intersects = raycasterRef.current.intersectObjects(modelGroupRef.current.children, true); raycaster.params.Line = { threshold: 4.0 };
raycaster.params.Points = { threshold: 4.0 };
raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), activeCameraRef.current);
let intersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
// Amostragem circular magnética de tolerância para peças esbeltas/finas
if (intersects.length === 0) {
const radiusOffsetsPx = [6, 12, 18];
const angles = [0, Math.PI / 4, Math.PI / 2, (3 * Math.PI) / 4, Math.PI, (5 * Math.PI) / 4, (3 * Math.PI) / 2, (7 * Math.PI) / 4];
for (const rPx of radiusOffsetsPx) {
if (intersects.length > 0) break;
for (const angle of angles) {
const dx = ((rPx * Math.cos(angle)) / rect.width) * 2;
const dy = -((rPx * Math.sin(angle)) / rect.height) * 2;
const samplePos = new THREE.Vector2(mouseX + dx, mouseY + dy);
raycaster.setFromCamera(samplePos, activeCameraRef.current);
const sampleIntersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
if (sampleIntersects.length > 0) {
intersects = sampleIntersects;
break;
}
}
}
}
if (intersects.length > 0) { if (intersects.length > 0) {
const hitPoint = intersects[0].point; const hitPoint = intersects[0].point;
@@ -1012,36 +1197,49 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
if (hitMesh) { if (hitMesh) {
const targetPieceMark = String(hitMesh.userData.pieceMark || '').trim().toUpperCase(); const targetPieceMark = String(hitMesh.userData.pieceMark || '').trim().toUpperCase();
const targetCleanMark = String(hitMesh.userData.cleanMark || targetPieceMark).trim().toUpperCase();
const targetAssemblyMark = String(hitMesh.userData.assemblyMark || '').trim().toUpperCase();
const targetCleanAssMark = String(hitMesh.userData.cleanAssemblyMark || targetCleanMark).trim().toUpperCase();
const targetPhase = hitMesh.userData.phase ? String(hitMesh.userData.phase).trim() : ''; const targetPhase = hitMesh.userData.phase ? String(hitMesh.userData.phase).trim() : '';
if (targetPieceMark) { if (targetPieceMark || targetCleanMark) {
const nextMap = new Map(selectedElementsMapRef.current); const nextMap = new Map(selectedElementsMapRef.current);
// Agrupa todas as sub-malhas por elemento físico (ifcId único) // Agrupa todas as sub-malhas por elemento físico único (ifcId ?? expressID)
const meshesByIfcId = new Map<number, { meshes: THREE.Mesh[]; sampleMesh: THREE.Mesh }>(); const meshesByElementId = new Map<number, { meshes: THREE.Mesh[]; sampleMesh: THREE.Mesh }>();
modelGroupRef.current.traverse((child) => { modelGroupRef.current.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.visible) { if ((child as THREE.Mesh).isMesh && child.visible) {
const mesh = child as THREE.Mesh; const mesh = child as THREE.Mesh;
const pmark = String(mesh.userData.pieceMark || '').trim().toUpperCase(); const pmark = String(mesh.userData.pieceMark || '').trim().toUpperCase();
const cmark = String(mesh.userData.cleanMark || pmark).trim().toUpperCase();
const amark = String(mesh.userData.assemblyMark || '').trim().toUpperCase();
const cleanAm = String(mesh.userData.cleanAssemblyMark || cmark).trim().toUpperCase();
const phase = mesh.userData.phase ? String(mesh.userData.phase).trim() : ''; const phase = mesh.userData.phase ? String(mesh.userData.phase).trim() : '';
// Mesma marca e mesma fase // Mesma marca (peça, conjunto ou limpa) e mesma fase (se especificada)
const isMatch = pmark === targetPieceMark && (!targetPhase || !phase || phase === targetPhase); const isMarkMatch =
if (isMatch) { (pmark && pmark === targetPieceMark) ||
const ifcId = mesh.userData.ifcId; (cmark && cmark === targetCleanMark) ||
if (ifcId !== undefined) { (targetAssemblyMark && amark === targetAssemblyMark) ||
if (!meshesByIfcId.has(ifcId)) { (targetCleanAssMark && cleanAm === targetCleanAssMark);
meshesByIfcId.set(ifcId, { meshes: [], sampleMesh: mesh });
const isPhaseMatch = !targetPhase || !phase || phase === targetPhase;
if (isMarkMatch && isPhaseMatch) {
const elId = mesh.userData.ifcId ?? mesh.userData.expressID;
if (elId !== undefined) {
if (!meshesByElementId.has(elId)) {
meshesByElementId.set(elId, { meshes: [], sampleMesh: mesh });
} }
meshesByIfcId.get(ifcId)!.meshes.push(mesh); meshesByElementId.get(elId)!.meshes.push(mesh);
} }
} }
} }
}); });
// Pinta e adiciona cada peça física real como 1 unidade // Pinta e adiciona cada peça física real como 1 unidade
meshesByIfcId.forEach(({ meshes, sampleMesh }, ifcId) => { meshesByElementId.forEach(({ meshes, sampleMesh }, elId) => {
meshes.forEach((m) => { meshes.forEach((m) => {
if (!m.userData.isMagentaSelected) { if (!m.userData.isMagentaSelected) {
m.userData.originalMaterial = m.material; m.userData.originalMaterial = m.material;
@@ -1050,18 +1248,44 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
m.material = magentaMaterialRef.current; m.material = magentaMaterialRef.current;
}); });
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(ifcId)) || { const rawPiece = modelData?.pieceByExpressID.get(elId);
expressID: ifcId, const rawMark = sampleMesh.userData.pieceMark || rawPiece?.pieceMark || '';
guid: '', const phase = sampleMesh.userData.phase || rawPiece?.phase || targetPhase;
name: sampleMesh.userData.section || 'Peça', const singlePartMark = sampleMesh.userData.singlePartMark || rawPiece?.singlePartMark;
type: 'PIECE',
pieceMark: sampleMesh.userData.pieceMark, const prod = findProductionDataForPiece(productionData, {
section: sampleMesh.userData.section, pieceMark: rawMark,
phase: sampleMesh.userData.phase || targetPhase, cleanMark: sampleMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: sampleMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: sampleMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
phase,
}, selectedOF);
const { displayMark, displaySection } = resolvePieceEngineeringDetails(
rawMark,
sampleMesh.userData.section || rawPiece?.section,
phase,
singlePartMark,
prod,
selectedOF
);
const piece: PieceInfo = {
expressID: elId,
guid: sampleMesh.userData.guid || rawPiece?.guid || '',
name: sampleMesh.userData.name || rawPiece?.name || 'Peça',
type: sampleMesh.userData.type || rawPiece?.type || 'PIECE',
pieceMark: displayMark,
cleanMark: sampleMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: sampleMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: sampleMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
section: displaySection,
phase,
singlePartMark,
}; };
nextMap.set(ifcId, { nextMap.set(elId, {
expressID: ifcId, expressID: elId,
piece, piece,
meshes, meshes,
}); });
@@ -1097,7 +1321,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const selectedPiecesList: SelectedPieceItem[] = Array.from(selectedElementsMap.values()).map( const selectedPiecesList: SelectedPieceItem[] = Array.from(selectedElementsMap.values()).map(
({ expressID, piece }) => { ({ expressID, piece }) => {
const pmark = piece.pieceMark || 'Sem Marca'; const pmark = piece.pieceMark || 'Sem Marca';
const prod = productionData?.get(pmark); const prod = findProductionDataForPiece(productionData, piece, selectedOF);
return { return {
id: String(expressID), id: String(expressID),
pieceMark: pmark, pieceMark: pmark,
@@ -1142,20 +1366,58 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
onSelectProcess={setSelectedProcess} onSelectProcess={setSelectedProcess}
onFitView={() => fitModelToView()} onFitView={() => fitModelToView()}
onToggleFullscreen={handleToggleFullscreen} onToggleFullscreen={handleToggleFullscreen}
brightness={brightness}
onBrightnessChange={setBrightness}
/> />
{/* Top Right: ViewCube + Painel de Ações de Seleção de Peças */} {/* Top Right: ViewCube + Controles Rápidos de Câmera + Painel de Ações de Seleção de Peças */}
<div className="absolute top-4 right-4 z-20 flex flex-col items-end gap-2.5 pointer-events-auto"> <div className="absolute top-4 right-4 z-20 flex flex-col items-stretch gap-2 pointer-events-auto w-[140px]">
<ViewCube onSelectView={handleViewCubeSelect} mainCameraRef={activeCameraRef} /> {/* Bloco Superior: Botões de Navegação + ViewCube perfeitamente alinhados */}
<div className="w-full flex items-start justify-between">
{/* Botões Rápidos de Navegação ao lado do Cubo (Ortogonalidade e Enquadrar) */}
<div className="flex flex-col gap-1.5 items-center justify-center pt-0.5">
{/* Botão Alternar Vista Ortogonal / Perspectiva */}
<button
onClick={handleToggleCamera}
className={`w-9 h-9 flex items-center justify-center rounded-2xl backdrop-blur-md border transition-all duration-150 active:scale-95 shadow-lg ${
isOrthographic
? 'bg-emerald-500 text-white border-emerald-400 shadow-emerald-500/30 dark:bg-emerald-600 dark:border-emerald-500'
: 'bg-white/95 dark:bg-slate-900/95 text-slate-700 dark:text-slate-200 border-slate-300 dark:border-slate-700 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-slate-100 dark:hover:bg-slate-800 shadow-slate-300/30 dark:shadow-slate-950/50'
}`}
title={
isOrthographic
? 'Câmera Ortogonal (Paralela Técnica) Ativa. Clique para mudar para Perspectiva.'
: 'Câmera Perspectiva (3D Cônica) Ativa. Clique para mudar para Ortogonal.'
}
>
<Compass className={`w-4 h-4 ${isOrthographic ? 'text-white' : 'text-emerald-600 dark:text-emerald-400'}`} />
</button>
{/* Botão Enquadrar / Centralizar Estrutura (Fit to Screen) */}
<button
onClick={() => fitModelToView(modelGroupRef.current)}
disabled={!modelData}
className={`w-9 h-9 flex items-center justify-center rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 transition-all duration-150 active:scale-95 ${
modelData
? 'text-slate-700 hover:text-cyan-600 dark:text-slate-200 dark:hover:text-cyan-400 hover:bg-slate-100 dark:hover:bg-slate-800 cursor-pointer'
: 'text-slate-400 dark:text-slate-600 opacity-40 cursor-not-allowed'
}`}
title="Centralizar e Enquadrar Estrutura na Janela (Fit to Screen)"
>
<Focus className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
</button>
</div>
<ViewCube onSelectView={handleViewCubeSelect} mainCameraRef={activeCameraRef} />
</div>
{/* Card de Controle de Seleção Flutuante Harmonizado Abaixo do Cubo */} {/* Card de Controle de Seleção Flutuante Harmonizado Abaixo do Cubo */}
<div <div
className={`flex flex-col items-stretch gap-1.5 p-2 rounded-2xl backdrop-blur-md border transition-all duration-200 shadow-lg ${ className={`w-full flex flex-col items-stretch gap-1.5 p-2 rounded-2xl backdrop-blur-md border transition-all duration-200 shadow-lg ${
selectedCount > 0 selectedCount > 0
? 'bg-white/95 dark:bg-slate-900/95 border-slate-300 dark:border-slate-700 shadow-slate-300/40 dark:shadow-slate-950/60 ring-1 ring-blue-500/20' ? 'bg-white/95 dark:bg-slate-900/95 border-slate-300 dark:border-slate-700 shadow-slate-300/40 dark:shadow-slate-950/60 ring-1 ring-blue-500/20'
: 'bg-white/80 dark:bg-slate-900/70 border-slate-200 dark:border-slate-800/80 opacity-75 hover:opacity-100' : 'bg-white/80 dark:bg-slate-900/70 border-slate-200 dark:border-slate-800/80 opacity-75 hover:opacity-100'
}`} }`}
style={{ minWidth: '130px' }}
> >
{/* Badge Indicador de Seleção Ativa Sóbrio */} {/* Badge Indicador de Seleção Ativa Sóbrio */}
{selectedCount > 0 && ( {selectedCount > 0 && (
@@ -1167,11 +1429,11 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
</div> </div>
)} )}
{/* Botão Gerar Lista da Seleção (Azul Escuro no claro / Azul Elegante no escuro) */} {/* Botão Gerar Lista da Seleção */}
<button <button
onClick={() => setIsSelectionModalOpen(true)} onClick={() => setIsSelectionModalOpen(true)}
disabled={selectedCount === 0} disabled={selectedCount === 0}
className={`flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[11px] font-bold transition-all duration-200 cursor-pointer ${ className={`w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-xl text-[11px] font-bold transition-all duration-200 cursor-pointer ${
selectedCount > 0 selectedCount > 0
? 'bg-blue-950 hover:bg-blue-900 text-white shadow-sm dark:bg-blue-600 dark:hover:bg-blue-500 scale-[1.02] active:scale-[0.98]' ? 'bg-blue-950 hover:bg-blue-900 text-white shadow-sm dark:bg-blue-600 dark:hover:bg-blue-500 scale-[1.02] active:scale-[0.98]'
: 'bg-slate-100 dark:bg-slate-800/50 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-800 cursor-not-allowed opacity-50' : 'bg-slate-100 dark:bg-slate-800/50 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-800 cursor-not-allowed opacity-50'
@@ -1186,11 +1448,11 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
<span className="whitespace-nowrap">Gerar lista</span> <span className="whitespace-nowrap">Gerar lista</span>
</button> </button>
{/* Botão Limpar Seleção Harmonizado */} {/* Botão Limpar Seleção */}
<button <button
onClick={handleClearSelection} onClick={handleClearSelection}
disabled={selectedCount === 0} disabled={selectedCount === 0}
className={`flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-xl text-[10px] font-semibold transition-all duration-200 cursor-pointer ${ className={`w-full flex items-center justify-center gap-1.5 px-2 py-1 rounded-xl text-[10px] font-semibold transition-all duration-200 cursor-pointer ${
selectedCount > 0 selectedCount > 0
? 'text-slate-600 hover:text-rose-600 hover:bg-rose-50 border border-slate-200 dark:border-slate-700 dark:text-slate-400 dark:hover:text-rose-300 dark:hover:bg-rose-950/30 active:scale-[0.98]' ? 'text-slate-600 hover:text-rose-600 hover:bg-rose-50 border border-slate-200 dark:border-slate-700 dark:text-slate-400 dark:hover:text-rose-300 dark:hover:bg-rose-950/30 active:scale-[0.98]'
: 'text-slate-400 dark:text-slate-600 border border-transparent cursor-not-allowed opacity-40' : 'text-slate-400 dark:text-slate-600 border border-transparent cursor-not-allowed opacity-40'
@@ -1203,24 +1465,21 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
</div> </div>
{/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */} {/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */}
<div <div className="w-full flex items-center gap-1.5 p-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto">
className="flex items-center gap-1.5 p-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto"
style={{ minWidth: '130px' }}
>
{/* Botão Rotacionar / Tombar 90° */} {/* Botão Rotacionar / Tombar 90° */}
<button <button
onClick={handleRotate90X} onClick={handleRotate90X}
disabled={!modelData} disabled={!modelData}
className={`flex-1 flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[11px] font-bold transition-all duration-150 active:scale-95 ${ className={`flex-1 min-w-0 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-xl text-[10px] font-bold transition-all duration-150 active:scale-95 ${
modelData modelData
? 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-100 cursor-pointer shadow-sm' ? 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-100 cursor-pointer shadow-sm'
: 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50' : 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50'
}`} }`}
title="Girar o modelo 90° no eixo de tombamento (adequar orientação Z/Y)" title="Girar o modelo 90° no eixo de tombamento (adequar orientação Z/Y)"
> >
<RotateCw className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" /> <RotateCw className="w-3 h-3 text-blue-600 dark:text-blue-400 shrink-0" />
<span className="whitespace-nowrap">Girar 90°</span> <span className="whitespace-nowrap">Girar 90°</span>
<span className="text-[10px] font-mono font-normal text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-950 px-1 py-0.2 rounded border border-slate-200 dark:border-slate-800"> <span className="text-[9px] font-mono font-normal text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-950 px-1 py-0.2 rounded border border-slate-200 dark:border-slate-800 shrink-0">
{rotationX}° {rotationX}°
</span> </span>
</button> </button>
@@ -1229,7 +1488,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
<button <button
onClick={handleToggleLockRotation} onClick={handleToggleLockRotation}
disabled={!modelData} disabled={!modelData}
className={`flex items-center justify-center p-1.5 rounded-xl transition-all duration-200 active:scale-95 border ${ className={`flex items-center justify-center p-1.5 rounded-xl transition-all duration-200 active:scale-95 border shrink-0 ${
!modelData !modelData
? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40' ? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40'
: isRotationLocked : isRotationLocked
@@ -1243,28 +1502,56 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
} }
> >
{isRotationLocked ? ( {isRotationLocked ? (
<Lock className="w-4 h-4 text-emerald-600 dark:text-emerald-400" /> <Lock className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
) : ( ) : (
<Unlock className="w-4 h-4 text-amber-600 dark:text-amber-400" /> <Unlock className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400" />
)} )}
</button> </button>
</div> </div>
{/* Card de Luminosidade Expandido (Alinhado com a largura uniforme da coluna) */}
<div
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto"
title="Ajuste de Luminosidade da Estrutura (arraste para clarear ou suavizar sombras das peças)"
>
<button
type="button"
onClick={() => setBrightness(100)}
title="Clique para resetar luminosidade em 100%"
className="hover:scale-110 active:scale-95 transition-transform shrink-0 cursor-pointer"
>
<SunMedium className="w-3.5 h-3.5 text-amber-500" />
</button>
<input
type="range"
min="40"
max="220"
step="5"
value={brightness}
onChange={(e) => setBrightness(Number(e.target.value))}
className="flex-1 min-w-0 h-1.5 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
/>
<span className="text-[10px] font-mono font-bold text-slate-600 dark:text-slate-300 min-w-[28px] text-right shrink-0">
{brightness}%
</span>
</div>
</div> </div>
{/* Hover Tooltip (1.0s delay trigger) */} {/* Hover Tooltip (1.0s delay trigger) */}
<PieceHoverTooltip <PieceHoverTooltip
piece={hoveredPiece} piece={hoveredPiece}
position={hoverPosition} position={hoverPosition}
productionInfo={ productionInfo={(() => {
hoveredPiece?.pieceMark && productionData const prod = findProductionDataForPiece(productionData, hoveredPiece, selectedOF);
return prod
? { ? {
pointedQty: productionData.get(hoveredPiece.pieceMark)?.pointedQtd, pointedQty: prod.pointedQtd,
totalQty: productionData.get(hoveredPiece.pieceMark)?.totalQtd, totalQty: prod.totalQtd,
currentStage: productionData.get(hoveredPiece.pieceMark)?.currentProcessName, currentStage: prod.currentProcessName,
stageColor: productionData.get(hoveredPiece.pieceMark)?.processColor, stageColor: prod.processColor,
} }
: undefined : undefined;
} })()}
/> />
{/* Modal de Lista e Exportação da Seleção 3D */} {/* Modal de Lista e Exportação da Seleção 3D */}
@@ -0,0 +1,264 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { DefectItem } from '@/types/analisador3d';
import {
AlertTriangle,
CheckCircle2,
AlertCircle,
Copy,
Crosshair,
Layers,
Wrench,
Factory,
ShieldAlert,
ArrowRight
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface PieceAuditDetailModalProps {
isOpen: boolean;
onClose: () => void;
defect: DefectItem | null;
onFocus3D?: (coords?: { x: number; y: number; z: number }) => void;
}
export const PieceAuditDetailModal: React.FC<PieceAuditDetailModalProps> = ({
isOpen,
onClose,
defect,
onFocus3D,
}) => {
const { toast } = useToast();
if (!defect) return null;
const handleCopy = () => {
const text = `[AUDITORIA 3D TRACKSTEEL]\nPeça: ${defect.pecaMarca}\nConjunto: ${defect.conjuntoMarca || 'N/A'}\nTipo: ${defect.tipo}\nGravidade: ${defect.gravidade}\nDescrição: ${defect.descricao}\nRecomendação: ${defect.recomendacao}`;
navigator.clipboard.writeText(text);
toast({
title: 'Copiado para Área de Transferência',
description: 'Dados técnicos copiados com sucesso.',
});
};
const getSeverityBadge = () => {
switch (defect.gravidade) {
case 'critico':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-red-500/10 text-red-500 border border-red-500/20">
<AlertTriangle className="w-3.5 h-3.5" />
CRÍTICO
</span>
);
case 'alerta':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-500 border border-amber-500/20">
<AlertCircle className="w-3.5 h-3.5" />
ALERTA
</span>
);
default:
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-500/10 text-blue-500 border border-blue-500/20">
<CheckCircle2 className="w-3.5 h-3.5" />
INFORMATIVO
</span>
);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100 p-6 shadow-2xl">
<DialogHeader className="border-b border-slate-800 pb-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="p-2 bg-indigo-500/10 text-indigo-400 rounded-lg border border-indigo-500/20">
<ShieldAlert className="w-5 h-5" />
</span>
<div>
<DialogTitle className="text-lg font-bold text-white flex items-center gap-2">
Diagnóstico Técnico • {defect.pecaMarca}
{getSeverityBadge()}
</DialogTitle>
<DialogDescription className="text-xs text-slate-400">
{defect.titulo}
</DialogDescription>
</div>
</div>
<button
onClick={handleCopy}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition"
title="Copiar relatório deste defeito"
>
<Copy className="w-4 h-4" />
</button>
</div>
</DialogHeader>
<div className="space-y-4 py-3 text-sm">
{/* Card Descrição do Problema */}
<div className="p-3.5 bg-slate-950/60 border border-slate-800 rounded-lg">
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5 text-indigo-400" />
Descrição do Ocorrido / Inconformidade
</h4>
<p className="text-slate-200 leading-relaxed text-xs sm:text-sm">
{defect.descricao}
</p>
{defect.deslocamentoMm && (
<div className="mt-2.5 pt-2 border-t border-slate-800/80 flex items-center gap-4 text-xs font-mono">
<span className="text-slate-400">Deslocamento:</span>
<span className="text-amber-400">ΔX: {defect.deslocamentoMm.x}mm</span>
<span className="text-amber-400">ΔY: {defect.deslocamentoMm.y}mm</span>
<span className="text-amber-400">ΔZ: {defect.deslocamentoMm.z}mm</span>
</div>
)}
</div>
{/* Grid de Relação Peça vs Peça Conectada */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3 bg-slate-950/40 border border-slate-800 rounded-lg">
<span className="text-xs text-slate-400 font-medium flex items-center gap-1 mb-1">
<Layers className="w-3.5 h-3.5 text-indigo-400" />
Peça Auditada
</span>
<div className="font-mono font-bold text-white text-base">{defect.pecaMarca}</div>
<div className="text-xs text-slate-400 mt-1">
Conjunto:{' '}
<strong className="text-slate-300 font-mono">
{defect.conjuntoMarca || 'N/A'}
</strong>
</div>
<div className="text-xs text-slate-400">
Fase:{' '}
<strong className="text-slate-300">{defect.fase || 'Não definida'}</strong>
</div>
</div>
<div className="p-3 bg-slate-950/40 border border-slate-800 rounded-lg">
<span className="text-xs text-slate-400 font-medium flex items-center gap-1 mb-1">
<Wrench className="w-3.5 h-3.5 text-cyan-400" />
Peça Conectada (Adjacente)
</span>
{defect.pecaConectadaMarca ? (
<>
<div className="font-mono font-bold text-cyan-300 text-base">
{defect.pecaConectadaMarca}
</div>
<div className="text-xs text-slate-400 mt-1">
Conjunto:{' '}
<strong className="text-slate-300 font-mono">
{defect.pecaConectadaConjunto || 'N/A'}
</strong>
</div>
<div className="text-xs text-cyan-400 mt-1">
Ligação: Furação faceada / Parafuso
</div>
</>
) : (
<div className="text-xs text-slate-500 italic mt-2">
Elemento avulso ou modificação intrínseca da peça.
</div>
)}
</div>
</div>
{/* Situação no Chão de Fábrica */}
<div className="p-3.5 bg-slate-950/60 border border-slate-800 rounded-lg">
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Factory className="w-3.5 h-3.5 text-amber-400" />
Impacto no Chão de Fábrica (Tracksteel Engine)
</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div>
<span className="text-slate-500 block">Etapa Atual</span>
<span
className="font-medium px-1.5 py-0.5 rounded text-[11px] inline-block mt-0.5"
style={{
backgroundColor: `${defect.processoCor || '#64748b'}25`,
color: defect.processoCor || '#94a3b8',
}}
>
{defect.processoAtual || 'Pendente'}
</span>
</div>
<div>
<span className="text-slate-500 block">Qtd Apontada</span>
<span className="font-mono font-bold text-slate-200 mt-0.5 block">
{defect.quantidadeApontada || 0} de {defect.quantidadeAfetada} un
</span>
</div>
<div>
<span className="text-slate-500 block">Peso Total</span>
<span className="font-mono font-bold text-slate-200 mt-0.5 block">
{defect.pesoTotalKg ? `${defect.pesoTotalKg.toFixed(1)} kg` : '—'}
</span>
</div>
<div>
<span className="text-slate-500 block">Risco de Retrabalho</span>
<span
className={`font-bold mt-0.5 uppercase block ${
defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto'
? 'text-red-400 animate-pulse'
: defect.riscoFabril === 'medio'
? 'text-amber-400'
: 'text-emerald-400'
}`}
>
{defect.riscoFabril}
</span>
</div>
</div>
</div>
{/* Recomendação de Engenharia */}
<div className="p-3.5 bg-indigo-950/30 border border-indigo-900/50 rounded-lg">
<h4 className="text-xs font-semibold text-indigo-300 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
Ação Corretiva Recomendada
</h4>
<p className="text-xs sm:text-sm text-indigo-100 leading-relaxed">
{defect.recomendacao}
</p>
</div>
</div>
{/* Rodapé com Botões */}
<div className="border-t border-slate-800 pt-4 flex items-center justify-between">
<button
onClick={onClose}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition"
>
Fechar
</button>
{defect.coordenadas3D && onFocus3D && (
<button
onClick={() => {
onFocus3D(defect.coordenadas3D);
onClose();
}}
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-xs font-medium shadow transition"
>
<Crosshair className="w-4 h-4" />
<span>Focar e Isolar no Visualizador 3D</span>
</button>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -62,6 +62,15 @@ export const PieceHoverTooltip: React.FC<PieceHoverTooltipProps> = ({
</div> </div>
)} )}
{piece.singlePartMark && piece.singlePartMark !== piece.pieceMark && (
<div className="flex items-center justify-between text-slate-300">
<span className="text-slate-400">Sub-peça / Corte:</span>
<span className="font-semibold text-slate-200 font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
Pos. {piece.singlePartMark}
</span>
</div>
)}
{piece.childrenIDs && piece.childrenIDs.length > 0 && ( {piece.childrenIDs && piece.childrenIDs.length > 0 && (
<div className="flex items-center justify-between text-slate-300"> <div className="flex items-center justify-between text-slate-300">
<span className="text-slate-400">Componentes:</span> <span className="text-slate-400">Componentes:</span>
+355 -103
View File
@@ -22,8 +22,12 @@ import {
Moon, Moon,
FileSpreadsheet, FileSpreadsheet,
Grid as GridIcon, Grid as GridIcon,
Bell Bell,
RotateCw,
Lock,
Unlock
} from 'lucide-react'; } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
export interface ProductionPieceStatus { export interface ProductionPieceStatus {
pecaId?: string; pecaId?: string;
@@ -38,6 +42,91 @@ export interface ProductionPieceStatus {
processesCompleted?: string[]; processesCompleted?: string[];
processPointedQtds?: Record<string, number>; processPointedQtds?: Record<string, number>;
hasDetalhamento?: boolean; hasDetalhamento?: boolean;
perfilPrincipal?: string;
descricao?: string;
}
/**
* 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;
}
/**
* Helper para formatar a Marca Principal da Peça no padrão de Engenharia (OF-Fase-Marca, ex: B138-1-59 / B138-2-10)
* e obter o Perfil/Bitola oficial a partir do banco de dados ou do modelo 3D.
*/
function resolvePieceEngineeringDetails(
rawPieceMark: string,
rawSection: string | undefined,
phase: string | undefined,
singlePartMark: string | undefined,
prod: ProductionPieceStatus | undefined,
selectedOF: string | undefined
): {
displayMark: string;
displaySection: string;
} {
const baseMark = prod?.marca || rawPieceMark || 'Peça';
let displayMark = baseMark;
// Se tivermos OF e Fase e a marca for pura (ex: "1" ou "8" ou "59"), formata como "B138-1-59"
if (selectedOF && phase && !baseMark.toUpperCase().startsWith(selectedOF.toUpperCase())) {
displayMark = `${selectedOF}-${phase}-${baseMark}`;
}
// Perfil / Bitola oficial: prioriza banco de produção (TS_ERP.pecas.perfil_principal), seguido de section limpo do modelo
let displaySection = prod?.perfilPrincipal || '';
if (!displaySection || displaySection === '1001' || displaySection === 'Perfil') {
if (rawSection && rawSection !== '1001' && rawSection !== 'Perfil') {
displaySection = rawSection;
} else {
displaySection = prod?.descricao || 'Perfil';
}
}
return { displayMark, displaySection };
} }
interface SmartModelViewer3DProps { interface SmartModelViewer3DProps {
@@ -46,6 +135,7 @@ interface SmartModelViewer3DProps {
selectedOF: string; selectedOF: string;
cliente?: string; cliente?: string;
phasesList: string[]; phasesList: string[];
initialRotation?: { x: number; y: number; z: number } | null;
onVoltar: () => void; onVoltar: () => void;
} }
@@ -65,6 +155,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
selectedOF, selectedOF,
cliente, cliente,
phasesList, phasesList,
initialRotation,
onVoltar, onVoltar,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -97,6 +188,11 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
// Modal de Lista da Seleção // Modal de Lista da Seleção
const [isSelectionModalOpen, setIsSelectionModalOpen] = useState<boolean>(false); const [isSelectionModalOpen, setIsSelectionModalOpen] = useState<boolean>(false);
// Controles de Rotação 90° e Trava com Cadeado (Memória de Posição)
const [rotationX, setRotationX] = useState<number>(0);
const [rotationY, setRotationY] = useState<number>(0);
const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false);
// Seleção e Toast Discreto // Seleção e Toast Discreto
interface SelectedElement { interface SelectedElement {
expressID: number; expressID: number;
@@ -372,7 +468,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
controlsRef.current.update(); controlsRef.current.update();
}, []); }, []);
// 3. Montar malhas do modelo IFC na cena Three.js // 3. Montar malhas do modelo IFC/WRL na cena Three.js & Aplicar Rotação Memorizada
useEffect(() => { useEffect(() => {
if (!modelData || !sceneRef.current) return; if (!modelData || !sceneRef.current) return;
@@ -384,11 +480,87 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
modelGroupRef.current = group; modelGroupRef.current = group;
sceneRef.current.add(group); sceneRef.current.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 no grupo do modelo Three.js (corrige modelos tombados)
group.rotation.set(
THREE.MathUtils.degToRad(savedX),
THREE.MathUtils.degToRad(savedY),
0
);
group.updateMatrixWorld(true);
// Enquadra a câmera automaticamente após carregar o modelo // Enquadra a câmera automaticamente após carregar o modelo
setTimeout(() => { setTimeout(() => {
fitModelToView(); fitModelToView();
}, 150); }, 150);
}, [modelData, fitModelToView]); }, [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();
smartAudio.playClick();
}, [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) return;
if (!isRotationLocked) {
setIsRotationLocked(true);
const rotData = { x: rotationX, y: rotationY, z: 0 };
localStorage.setItem(`tracksteel_model_rot_${selectedOF}`, JSON.stringify(rotData));
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);
}
smartAudio.playSuccess();
} else {
setIsRotationLocked(false);
smartAudio.playClick();
}
}, [selectedOF, isRotationLocked, rotationX, rotationY]);
// 4. Aplicação de Filtros (Fase e Processo) e Cores de Produção idêntico ao modo desktop // 4. Aplicação de Filtros (Fase e Processo) e Cores de Produção idêntico ao modo desktop
useEffect(() => { useEffect(() => {
@@ -557,7 +729,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
} }
// 4.3. Preserva o destaque magenta se a peça estiver selecionada pelo operador // 4.3. Preserva o destaque magenta se a peça estiver selecionada pelo operador
const ifcId = mesh.userData.ifcId; const ifcId = mesh.userData.ifcId ?? mesh.userData.expressID;
if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) { if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) {
mesh.userData.originalMaterial = mesh.material; mesh.userData.originalMaterial = mesh.material;
mesh.material = magentaMaterialRef.current; mesh.material = magentaMaterialRef.current;
@@ -620,7 +792,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
else if (hitObj.parent && (hitObj.parent as THREE.Mesh).isMesh) hitMesh = hitObj.parent as THREE.Mesh; else if (hitObj.parent && (hitObj.parent as THREE.Mesh).isMesh) hitMesh = hitObj.parent as THREE.Mesh;
if (hitMesh && hitMesh.visible) { if (hitMesh && hitMesh.visible) {
const targetIfcId = hitMesh.userData.ifcId; const targetIfcId = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
if (targetIfcId === undefined) return; if (targetIfcId === undefined) return;
const nextMap = new Map(selectedElementsMapRef.current); const nextMap = new Map(selectedElementsMapRef.current);
@@ -644,24 +816,51 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
// Marca a peça física inteira em Magenta permanente // Marca a peça física inteira em Magenta permanente
const relatedMeshes: THREE.Mesh[] = []; const relatedMeshes: THREE.Mesh[] = [];
modelGroupRef.current.traverse((child) => { modelGroupRef.current.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.userData.ifcId === targetIfcId) { if ((child as THREE.Mesh).isMesh) {
const m = child as THREE.Mesh; const childId = child.userData.ifcId ?? child.userData.expressID;
m.userData.originalMaterial = m.material; if (childId === targetIfcId) {
m.material = magentaMaterialRef.current; const m = child as THREE.Mesh;
relatedMeshes.push(m); m.userData.originalMaterial = m.material;
m.material = magentaMaterialRef.current;
relatedMeshes.push(m);
}
} }
}); });
const pmark = hitMesh.userData.pieceMark; const rawPiece = modelData?.pieceByExpressID.get(targetIfcId);
const phase = hitMesh.userData.phase; const rawMark = hitMesh.userData.pieceMark || rawPiece?.pieceMark || '';
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || { const phase = hitMesh.userData.phase || rawPiece?.phase;
const singlePartMark = hitMesh.userData.singlePartMark || rawPiece?.singlePartMark;
const prodInfo = findProductionDataForPiece(productionData, {
pieceMark: rawMark,
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
phase,
}, selectedOF);
const { displayMark, displaySection } = resolvePieceEngineeringDetails(
rawMark,
hitMesh.userData.section || rawPiece?.section,
phase,
singlePartMark,
prodInfo,
selectedOF
);
const piece: PieceInfo = {
expressID: targetIfcId, expressID: targetIfcId,
guid: '', guid: hitMesh.userData.guid || rawPiece?.guid || '',
name: hitMesh.userData.section || 'Peça', name: hitMesh.userData.name || rawPiece?.name || 'Peça',
type: 'PIECE', type: hitMesh.userData.type || rawPiece?.type || 'PIECE',
pieceMark: pmark, pieceMark: displayMark,
section: hitMesh.userData.section, cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
phase: phase, assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
section: displaySection,
phase,
singlePartMark,
}; };
nextMap.set(targetIfcId, { nextMap.set(targetIfcId, {
@@ -672,22 +871,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
setSelectedElementsMap(nextMap); setSelectedElementsMap(nextMap);
smartAudio.playSuccess(); smartAudio.playSuccess();
// Abre o Toast discreto ao lado do toque com matching enriquecido // Abre o Toast com dados de engenharia enriquecidos
let prodInfo: ProductionPieceStatus | undefined = undefined;
if (productionData && productionData.size > 0 && pmark) {
const searchKeys = [
phase && selectedOF ? `${selectedOF}-${phase}-${pmark}` : '',
phase ? `${phase}-${pmark}` : '',
selectedOF ? `${selectedOF}-${pmark}` : '',
pmark,
pmark.toUpperCase(),
].filter(Boolean);
for (const k of searchKeys) {
const m = productionData.get(k);
if (m) { prodInfo = m; break; }
}
}
setToastInfo({ setToastInfo({
piece, piece,
position: { x: e.clientX, y: e.clientY }, position: { x: e.clientX, y: e.clientY },
@@ -715,26 +899,10 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
// Lista consolidada de peças físicas para o modal de seleção (Print 3) // Lista consolidada de peças físicas para o modal de seleção (Print 3)
const selectedPiecesList: SelectedPieceItem[] = useMemo(() => { const selectedPiecesList: SelectedPieceItem[] = useMemo(() => {
return Array.from(selectedElementsMap.values()).map(({ expressID, piece }) => { return Array.from(selectedElementsMap.values()).map(({ expressID, piece }) => {
const pmark = piece.pieceMark || 'Sem Marca'; const prod = findProductionDataForPiece(productionData, piece, selectedOF);
const phase = piece.phase;
let prod: ProductionPieceStatus | undefined = undefined;
if (productionData && productionData.size > 0 && pmark) {
const searchKeys = [
phase && selectedOF ? `${selectedOF}-${phase}-${pmark}` : '',
phase ? `${phase}-${pmark}` : '',
selectedOF ? `${selectedOF}-${pmark}` : '',
pmark,
pmark.toUpperCase(),
].filter(Boolean);
for (const k of searchKeys) {
const m = productionData.get(k);
if (m) { prod = m; break; }
}
}
return { return {
id: String(expressID), id: String(expressID),
pieceMark: pmark, pieceMark: piece.pieceMark || 'Sem Marca',
phase: piece.phase, phase: piece.phase,
section: piece.section, section: piece.section,
name: piece.name, name: piece.name,
@@ -772,13 +940,14 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
let left = toastInfo.position.x - rect.left + 14; let left = toastInfo.position.x - rect.left + 14;
let top = toastInfo.position.y - rect.top - 20; let top = toastInfo.position.y - rect.top - 20;
if (left + 230 > rect.width) { if (left + 310 > rect.width) {
left = Math.max(12, left - 245); left = Math.max(12, left - 325);
} }
if (top + 160 > rect.height) { if (top + 230 > rect.height) {
top = Math.max(12, rect.height - 170); top = Math.max(12, rect.height - 240);
} }
if (top < 12) top = 12; if (top < 12) top = 12;
if (left < 12) left = 12;
return { left: `${left}px`, top: `${top}px` }; return { left: `${left}px`, top: `${top}px` };
}, [toastInfo]); }, [toastInfo]);
@@ -940,7 +1109,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
</div> </div>
</div> </div>
{/* 2. Top Right: Cubo 3D Compacto + Card de Ações da Seleção (Print 3) */} {/* 2. Top Right: Cubo 3D Compacto + Card de Giro 90° + Card de Ações da Seleção */}
<div className="absolute top-16 right-3 z-20 flex flex-col items-end gap-2 pointer-events-auto"> <div className="absolute top-16 right-3 z-20 flex flex-col items-end gap-2 pointer-events-auto">
{/* Cubo Compacto Discreto */} {/* Cubo Compacto Discreto */}
<ViewCube <ViewCube
@@ -950,11 +1119,57 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
compact={true} compact={true}
/> />
{/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */}
<div className="w-[140px] 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
type="button"
onClick={handleRotate90X}
disabled={!modelData}
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 h-3 text-blue-600 dark:text-blue-400 shrink-0" />
<span className="whitespace-nowrap">Girar 90°</span>
<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>
{/* Botão Cadeado: Travar / Gravar ou Destravar */}
<button
type="button"
onClick={handleToggleLockRotation}
disabled={!modelData}
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
? '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-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
) : (
<Unlock className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400" />
)}
</button>
</div>
{/* Card Flutuante de Seleção com "Gerar lista da seleção" (Print 3) */} {/* Card Flutuante de Seleção com "Gerar lista da seleção" (Print 3) */}
{selectedCount > 0 && ( {selectedCount > 0 && (
<div <div
className="flex flex-col items-stretch gap-1.5 p-2 rounded-2xl bg-white/95 dark:bg-slate-900/95 border border-slate-300 dark:border-slate-700 shadow-xl dark:shadow-slate-950/80 backdrop-blur-md animate-in fade-in zoom-in-95 duration-200" className="w-[140px] flex flex-col items-stretch gap-1.5 p-2 rounded-2xl bg-white/95 dark:bg-slate-900/95 border border-slate-300 dark:border-slate-700 shadow-xl dark:shadow-slate-950/80 backdrop-blur-md animate-in fade-in zoom-in-95 duration-200"
style={{ minWidth: '135px' }}
> >
{/* Badge Indicador de Seleção Ativa */} {/* Badge Indicador de Seleção Ativa */}
<div className="flex items-center justify-center gap-1.5 px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 text-[10px] font-mono font-bold"> <div className="flex items-center justify-center gap-1.5 px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 text-[10px] font-mono font-bold">
@@ -992,7 +1207,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
)} )}
</div> </div>
{/* 3. Toast Discreto ao Lado da Peça Clicada */} {/* 3. Toast Discreto ao Lado da Peça Clicada (Réplica Idêntica do Modo Completo - Print 2) */}
{toastInfo && ( {toastInfo && (
<div <div
onClick={() => { onClick={() => {
@@ -1000,63 +1215,100 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
setToastInfo(null); setToastInfo(null);
}} }}
style={toastStyle} style={toastStyle}
className="absolute z-40 p-3 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-xl border border-fuchsia-500/50 shadow-2xl shadow-fuchsia-950/20 text-slate-800 dark:text-slate-100 min-w-[210px] max-w-[260px] animate-in fade-in zoom-in-95 duration-150 cursor-pointer pointer-events-auto select-none transition-all group" className="absolute z-40 p-4 rounded-2xl bg-slate-900/95 backdrop-blur-xl border border-cyan-500/40 shadow-2xl shadow-cyan-950/60 text-slate-100 min-w-[270px] max-w-[330px] animate-in fade-in zoom-in-95 duration-150 cursor-pointer pointer-events-auto select-none transition-all group"
title="Toque no toast para fechar (a peça continua marcada)" title="Toque no card para fechar (a peça continua marcada)"
> >
{/* Cabeçalho do Toast */} {/* Cabeçalho do Toast com Ícone Box e Marca Principal de Engenharia */}
<div className="flex items-center justify-between gap-1.5 pb-1.5 mb-1.5 border-b border-slate-200 dark:border-slate-800"> <div className="flex items-center justify-between gap-2 pb-2.5 mb-2.5 border-b border-slate-800">
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className="w-2 h-2 rounded-full bg-fuchsia-500 shadow-sm" /> <div className="p-1.5 rounded-lg bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 shrink-0">
<span className="text-xs font-black text-fuchsia-600 dark:text-fuchsia-300 font-mono tracking-tight truncate"> <Box className="w-4 h-4" />
{toastInfo.piece.pieceMark || 'Sem Marca'} </div>
</span> <div className="min-w-0">
<span className="text-[10px] uppercase font-mono tracking-widest text-slate-400 block leading-tight">
Marca Principal
</span>
<span className="text-base font-extrabold text-cyan-300 font-mono tracking-tight truncate block">
{toastInfo.piece.pieceMark || 'Sem Marca'}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{toastInfo.piece.phase && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold font-mono bg-slate-800 text-slate-300 border border-slate-700">
Fase {toastInfo.piece.phase}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setToastInfo(null);
}}
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div> </div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setToastInfo(null);
}}
className="p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div> </div>
{/* Dados Rápidos da Peça */} {/* Detalhes Técnicos da Peça */}
<div className="space-y-1 text-[11px]"> <div className="space-y-1.5 text-xs">
{toastInfo.piece.phase && (
<div className="flex items-center justify-between text-slate-600 dark:text-slate-300">
<span className="text-slate-400">Fase:</span>
<span className="font-bold text-slate-800 dark:text-slate-200">Fase {toastInfo.piece.phase}</span>
</div>
)}
{toastInfo.piece.section && ( {toastInfo.piece.section && (
<div className="flex items-center justify-between text-slate-600 dark:text-slate-300"> <div className="flex items-center justify-between text-slate-300">
<span className="text-slate-400">Perfil:</span> <span className="text-slate-400">Perfil / Bitola:</span>
<span className="font-mono font-semibold text-slate-800 dark:text-slate-100 truncate max-w-[120px]"> <span className="font-semibold text-white font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
{toastInfo.piece.section} {toastInfo.piece.section}
</span> </span>
</div> </div>
)} )}
{toastInfo.prod && (
<div className="pt-1 mt-1 border-t border-slate-200 dark:border-slate-800/80 flex items-center justify-between"> {toastInfo.piece.singlePartMark && toastInfo.piece.singlePartMark !== toastInfo.piece.pieceMark && (
<span className="text-slate-400">Status:</span> <div className="flex items-center justify-between text-slate-300">
<span <span className="text-slate-400">Sub-peça / Corte:</span>
className="font-bold px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wider" <span className="font-semibold text-slate-200 font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
style={{ Pos. {toastInfo.piece.singlePartMark}
backgroundColor: `${toastInfo.prod.processColor || '#10b981'}25`,
color: toastInfo.prod.processColor || '#10b981',
}}
>
{toastInfo.prod.currentProcessName || 'Produção'} ({toastInfo.prod.pointedQtd}/{toastInfo.prod.totalQtd})
</span> </span>
</div> </div>
)} )}
{/* Apontamentos e Etapa Atual */}
{toastInfo.prod && (
<div className="mt-3 pt-2.5 border-t border-slate-800/80 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-slate-400 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
Apontamento:
</span>
<span className="font-bold text-emerald-400 font-mono">
{toastInfo.prod.pointedQtd ?? 0} de {toastInfo.prod.totalQtd ?? 1} peças
</span>
</div>
{toastInfo.prod.currentProcessName && (
<div className="flex items-center justify-between text-[11px]">
<span className="text-slate-400 flex items-center gap-1">
<Clock className="w-3 h-3 text-amber-400" />
Etapa Atual:
</span>
<span
className="px-2 py-0.5 rounded-full font-bold uppercase tracking-wider text-[10px]"
style={{
backgroundColor: `${toastInfo.prod.processColor || '#10b981'}25`,
color: toastInfo.prod.processColor || '#10b981',
border: `1px solid ${toastInfo.prod.processColor || '#10b981'}40`,
}}
>
{toastInfo.prod.currentProcessName}
</span>
</div>
)}
</div>
)}
</div> </div>
{/* Dica para Fechar */} {/* Dica para Fechar */}
<div className="mt-2 pt-1 border-t border-slate-200 dark:border-slate-800 text-[9px] text-center text-slate-400 font-medium"> <div className="mt-2.5 pt-1.5 border-t border-slate-800 text-[9px] text-center text-slate-400 font-medium">
Toque no card para fechar ✕ Toque no card para fechar ✕
</div> </div>
</div> </div>
+76 -79
View File
@@ -16,7 +16,8 @@ import {
Wrench, Wrench,
Paintbrush, Paintbrush,
Truck, Truck,
Scissors Scissors,
SunMedium
} from 'lucide-react'; } from 'lucide-react';
interface Viewer3DSidebarProps { interface Viewer3DSidebarProps {
@@ -32,6 +33,8 @@ interface Viewer3DSidebarProps {
onSelectProcess?: (proc: string) => void; onSelectProcess?: (proc: string) => void;
onFitView: () => void; onFitView: () => void;
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
brightness?: number;
onBrightnessChange?: (val: number) => void;
} }
const FABRICATION_PROCESSES = [ const FABRICATION_PROCESSES = [
@@ -56,6 +59,8 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
onSelectProcess, onSelectProcess,
onFitView, onFitView,
onToggleFullscreen, onToggleFullscreen,
brightness = 100,
onBrightnessChange,
}) => { }) => {
const [isOpen, setIsOpen] = useState<boolean>(true); const [isOpen, setIsOpen] = useState<boolean>(true);
@@ -64,36 +69,36 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
{/* Container Principal da Barra Lateral */} {/* Container Principal da Barra Lateral */}
<div <div
className={`transition-all duration-300 ease-in-out flex flex-col bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl overflow-hidden ${ className={`transition-all duration-300 ease-in-out flex flex-col bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl overflow-hidden ${
isOpen ? 'w-64 max-h-[calc(100vh-14rem)]' : 'w-0 border-0 opacity-0 pointer-events-none' isOpen ? 'w-60 max-h-[calc(100%-2rem)]' : 'w-0 border-0 opacity-0 pointer-events-none'
}`} }`}
> >
{/* Cabeçalho do Sub-Menu */} {/* Cabeçalho do Sub-Menu Enxuto */}
<div className="flex items-center justify-between px-4 py-3 bg-slate-100/90 dark:bg-slate-950/80 border-b border-slate-200 dark:border-slate-800"> <div className="flex items-center justify-between px-3 py-1.5 bg-slate-100/90 dark:bg-slate-950/80 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Sliders className="w-4 h-4 text-cyan-600 dark:text-cyan-400" /> <Sliders className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<span className="text-xs font-bold uppercase tracking-wider text-slate-800 dark:text-slate-200"> <span className="text-[11px] font-bold uppercase tracking-wider text-slate-800 dark:text-slate-200">
Controles 3D Controles 3D
</span> </span>
</div> </div>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-cyan-100 dark:bg-cyan-900/50 text-cyan-700 dark:text-cyan-300 font-bold"> <span className="text-[9px] font-mono px-1 py-0.2 rounded bg-cyan-100 dark:bg-cyan-900/50 text-cyan-700 dark:text-cyan-300 font-bold">
SteelXR SteelXR
</span> </span>
</div> </div>
{/* Corpo com Rolagem Suave */} {/* Corpo Compactado com Rolagem Suave */}
<div className="flex-1 overflow-y-auto p-3 space-y-4 custom-scrollbar"> <div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
{/* Grupo 1: Destaque por Processo Industrial com Cores Correspondentes */} {/* Grupo 1: Destaque por Processo Industrial */}
<div className="space-y-1.5"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5"> <label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" /> <CheckCircle2 className="w-3 h-3 text-emerald-600 dark:text-emerald-400" />
Destaque por Processo Processo Fabril
</label> </label>
<span className="text-[9px] font-mono text-emerald-600 dark:text-emerald-400 font-bold"> <span className="text-[8px] font-mono text-emerald-600 dark:text-emerald-400 font-bold">
BIM + Fábrica BIM
</span> </span>
</div> </div>
<div className="grid grid-cols-3 gap-1.5 bg-slate-100 dark:bg-slate-950/60 p-1.5 rounded-xl border border-slate-200 dark:border-slate-800"> <div className="grid grid-cols-3 gap-1 bg-slate-100 dark:bg-slate-950/60 p-1 rounded-lg border border-slate-200 dark:border-slate-800">
{FABRICATION_PROCESSES.map((proc) => { {FABRICATION_PROCESSES.map((proc) => {
const Icon = proc.icon; const Icon = proc.icon;
const isSelected = selectedProcess === proc.id; const isSelected = selectedProcess === proc.id;
@@ -106,24 +111,22 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
? { ? {
backgroundColor: proc.color, backgroundColor: proc.color,
color: '#ffffff', color: '#ffffff',
boxShadow: `0 4px 12px ${proc.color}50`, boxShadow: `0 2px 8px ${proc.color}40`,
borderColor: proc.color, borderColor: proc.color,
} }
: { : {
backgroundColor: `${proc.color}18`, backgroundColor: `${proc.color}14`,
color: proc.color, color: proc.color,
borderColor: `${proc.color}35`, borderColor: `${proc.color}25`,
} }
} }
className={`flex flex-col items-center justify-center gap-1 py-2 px-1 rounded-lg text-[10px] font-bold border transition-all duration-200 hover:scale-[1.03] active:scale-[0.98] ${ className={`flex flex-col items-center justify-center gap-0.5 py-1 px-1 rounded-md text-[9.5px] font-bold border transition-all duration-150 hover:scale-[1.02] active:scale-[0.98] ${
isSelected isSelected ? 'border-transparent font-extrabold' : 'hover:brightness-110'
? 'shadow-md border-transparent font-extrabold'
: 'hover:brightness-110'
}`} }`}
title={`Destacar peças em ${proc.label} (${proc.color}) e não apontadas em aramado cinza`} title={`Destacar peças em ${proc.label}`}
> >
<Icon className="w-3.5 h-3.5 stroke-[2.2]" /> <Icon className="w-3 h-3 stroke-[2.2]" />
<span className="tracking-tight">{proc.label}</span> <span className="tracking-tight leading-none">{proc.label}</span>
</button> </button>
); );
})} })}
@@ -131,99 +134,93 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
</div> </div>
{/* Grupo 2: Modo de Cores Geral */} {/* Grupo 2: Modo de Cores Geral */}
<div className="space-y-1.5"> <div className="space-y-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5"> <label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<Palette className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /> <Palette className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
Modo de Cores Modo de Cores
</label> </label>
<div className="grid grid-cols-2 gap-1.5 bg-slate-100 dark:bg-slate-950/60 p-1 rounded-xl border border-slate-200 dark:border-slate-800"> <div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-slate-950/60 p-0.5 rounded-lg border border-slate-200 dark:border-slate-800">
<button <button
onClick={() => onColorModeChange('production')} onClick={() => onColorModeChange('production')}
className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-bold transition-all ${ className={`flex items-center justify-center gap-1 px-1.5 py-1 rounded-md text-[11px] font-bold transition-all ${
colorMode === 'production' colorMode === 'production'
? 'bg-cyan-600 text-white shadow-md shadow-cyan-600/30' ? 'bg-cyan-600 text-white shadow-sm'
: 'text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' : 'text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`} }`}
> >
<CheckCircle2 className="w-3.5 h-3.5" /> <CheckCircle2 className="w-3 h-3" />
<span>Produção</span> <span>Produção</span>
</button> </button>
<button <button
onClick={() => onColorModeChange('description')} onClick={() => onColorModeChange('description')}
className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-bold transition-all ${ className={`flex items-center justify-center gap-1 px-1.5 py-1 rounded-md text-[11px] font-bold transition-all ${
colorMode === 'description' colorMode === 'description'
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30' ? 'bg-amber-600 text-white shadow-sm'
: 'text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' : 'text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`} }`}
> >
<Box className="w-3.5 h-3.5" /> <Box className="w-3 h-3" />
<span>Padrão</span> <span>Padrão</span>
</button> </button>
</div> </div>
</div> </div>
{/* Grupo 3: Ambiente & Grade */} {/* Grupo 3: Ambiente & Grade */}
<div className="space-y-1.5"> <div className="space-y-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5"> <label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<GridIcon className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /> <GridIcon className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
Ambiente & Piso Ambiente & Piso
</label> </label>
<div> <button
{/* Toggle Grid */} onClick={onToggleGrid}
<button className={`w-full flex items-center justify-center gap-1.5 py-1 rounded-lg text-[11px] font-semibold border transition-all ${
onClick={onToggleGrid} showGrid
className={`w-full flex items-center justify-center gap-2 py-2 rounded-xl text-xs font-semibold border transition-all ${ ? 'bg-cyan-50 dark:bg-cyan-950/40 text-cyan-800 dark:text-cyan-300 border-cyan-300 dark:border-cyan-800 shadow-xs'
showGrid : 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300'
? 'bg-cyan-50 dark:bg-cyan-950/40 text-cyan-800 dark:text-cyan-300 border-cyan-300 dark:border-cyan-800 shadow-sm' }`}
: 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300' >
}`} <GridIcon className="w-3 h-3" />
> <span>Grade de Piso: {showGrid ? 'LIGADO' : 'DESL'}</span>
<GridIcon className="w-3.5 h-3.5" /> </button>
<span>Grade de Piso: {showGrid ? 'LIGADO' : 'DESL'}</span>
</button>
</div>
</div> </div>
{/* Grupo 4: Câmera & Projeção */} {/* Grupo 4: Câmera & Visualização */}
<div className="space-y-1.5"> <div className="space-y-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5"> <label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<Compass className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /> <Compass className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
Câmera & Visualização Câmera & Visualização
</label> </label>
<div className="grid grid-cols-2 gap-1.5"> <div className="grid grid-cols-2 gap-1">
{/* Câmera Toggle */}
<button <button
onClick={onToggleCamera} onClick={onToggleCamera}
className={`flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-xl text-xs font-semibold border transition-all ${ className={`flex items-center justify-center gap-1 px-1.5 py-1 rounded-lg text-[11px] font-semibold border transition-all ${
isOrthographic isOrthographic
? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-300 border-emerald-300 dark:border-emerald-800 shadow-sm' ? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-300 border-emerald-300 dark:border-emerald-800 shadow-xs'
: 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300' : 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300'
}`} }`}
> >
<Box className="w-3.5 h-3.5" /> <Box className="w-3 h-3" />
<span>{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span> <span>{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span>
</button> </button>
{/* Enquadrar / Reset View */}
<button <button
onClick={onFitView} onClick={onFitView}
className="flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-xl text-xs font-semibold bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all" className="flex items-center justify-center gap-1 px-1.5 py-1 rounded-lg text-[11px] font-semibold bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all"
title="Enquadrar / Centralizar Estrutura" title="Enquadrar / Centralizar Estrutura"
> >
<Focus className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /> <Focus className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
<span>Enquadrar</span> <span>Enquadrar</span>
</button> </button>
</div> </div>
</div> </div>
{/* Grupo 5: Opacidade da Estrutura */} {/* Grupo 5: Opacidade da Estrutura Compacta */}
<div className="space-y-1.5 bg-slate-50 dark:bg-slate-950/60 p-2.5 rounded-xl border border-slate-200 dark:border-slate-800"> <div className="space-y-1 bg-slate-50 dark:bg-slate-950/60 p-2 rounded-lg border border-slate-200 dark:border-slate-800">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-[11px]">
<span className="font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1.5"> <span className="font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
<Eye className="w-3.5 h-3.5 text-amber-500" /> <Eye className="w-3 h-3 text-amber-500" />
Opacidade Opacidade
</span> </span>
<span className="font-mono font-bold text-amber-700 dark:text-amber-400">{opacity}%</span> <span className="font-mono font-bold text-amber-700 dark:text-amber-400 text-[10px]">{opacity}%</span>
</div> </div>
<input <input
type="range" type="range"
@@ -231,9 +228,9 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
max={100} max={100}
value={opacity} value={opacity}
onChange={(e) => onOpacityChange(Number(e.target.value))} onChange={(e) => onOpacityChange(Number(e.target.value))}
className="w-full h-1.5 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500" className="w-full h-1 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
/> />
<div className="flex justify-between text-[10px] font-mono text-slate-400"> <div className="flex justify-between text-[9px] font-mono text-slate-400 pt-0.5">
<button onClick={() => onOpacityChange(25)} className="hover:text-amber-600">25%</button> <button onClick={() => onOpacityChange(25)} className="hover:text-amber-600">25%</button>
<button onClick={() => onOpacityChange(50)} className="hover:text-amber-600">50%</button> <button onClick={() => onOpacityChange(50)} className="hover:text-amber-600">50%</button>
<button onClick={() => onOpacityChange(75)} className="hover:text-amber-600">75%</button> <button onClick={() => onOpacityChange(75)} className="hover:text-amber-600">75%</button>
@@ -241,12 +238,12 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
</div> </div>
</div> </div>
{/* Botão Tela Cheia */} {/* Botão Tela Cheia Compacto */}
<button <button
onClick={onToggleFullscreen} onClick={onToggleFullscreen}
className="w-full flex items-center justify-center gap-2 py-2 bg-slate-100 hover:bg-cyan-50 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-white border border-slate-200 dark:border-slate-700 rounded-xl text-xs font-bold transition-all shadow-sm" className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-slate-100 hover:bg-cyan-50 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-white border border-slate-200 dark:border-slate-700 rounded-lg text-[11px] font-bold transition-all shadow-xs"
> >
<Maximize2 className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /> <Maximize2 className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
<span>Expandir Tela Cheia</span> <span>Expandir Tela Cheia</span>
</button> </button>
</div> </div>
+14
View File
@@ -118,6 +118,12 @@ export const routeConfig: RouteDefinition[] = [
guard: 'resource', guard: 'resource',
resourceKey: 'ofs-lista', resourceKey: 'ofs-lista',
}, },
{
path: '/ordens-fabricacao',
component: () => import('@/pages/OrdensFabricacao'),
guard: 'resource',
resourceKey: 'ofs-lista',
},
{ {
path: '/ofs/cronograma', path: '/ofs/cronograma',
component: () => import('@/pages/CronogramaOF'), component: () => import('@/pages/CronogramaOF'),
@@ -177,6 +183,14 @@ export const routeConfig: RouteDefinition[] = [
resourceKey: 'visualizador-3d', resourceKey: 'visualizador-3d',
}, },
// ── Analisador 3D (QA/QC & Auditoria Geométrica) ──────────
{
path: '/analisador-3d',
component: () => import('@/pages/Analisador3D'),
guard: 'resource',
resourceKey: 'analisador-3d',
},
// ── Painel Industrial ──────────────────────────────────── // ── Painel Industrial ────────────────────────────────────
{ {
path: '/painel-industrial', path: '/painel-industrial',
+5 -6
View File
@@ -118,14 +118,13 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
const ofNumber = apontamento.of_number; const ofNumber = apontamento.of_number;
const processoNome = apontamento.processo?.nome || 'Processo Desconhecido'; const processoNome = apontamento.processo?.nome || 'Processo Desconhecido';
// Calcular peso baseado no tipo de apontamento // Blindagem contra dupla contagem: apenas peças mestres computam peso nos gráficos de produção.
let pesoUnitario = 0; // Componentes são rastreados para controle de kit/corte, mas não somam peso na balança global.
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) { if (apontamento.tipo_apontamento !== 'peca') {
pesoUnitario = Number(apontamento.componente.peso_unitario); return;
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
pesoUnitario = Number(apontamento.peca.peso_unitario);
} }
const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
let pesoTotal = pesoUnitario * Number(apontamento.quantidade_produzida); let pesoTotal = pesoUnitario * Number(apontamento.quantidade_produzida);
// Para projeção, calcular média diária e multiplicar por 7 // Para projeção, calcular média diária e multiplicar por 7
+64
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { sincronizarPecaKitNoCorte, sincronizarTodosKitsOFCorte } from '@/utils/sincronizacaoKitsCorte';
// Função utilitária para retry de requisições // Função utilitária para retry de requisições
const retryRequest = async <T,>( const retryRequest = async <T,>(
@@ -378,6 +379,36 @@ interface InsertApontamentoData {
throw error; throw error;
} }
// REGRA DO KIT COMPLETO:
// Se apontou componente no processo de Corte, verificar se todos os componentes da peça pai completaram o kit.
// Se completou, cria automaticamente o apontamento da Peça Principal no Corte (tipo_apontamento = 'peca'),
// computando o peso integral da peça mestre no Corte e liberando-a para a Solda!
if (insertData.tipo_apontamento === 'componente' && insertData.peca_id) {
try {
const procAtual = processos.find(p => p.id === insertData.processo_id);
const isProcessoCorte = procAtual
? (procAtual.nome.toLowerCase().includes('corte') || procAtual.nome.toLowerCase().includes('prepar') || procAtual.ordem === 1)
: true;
if (isProcessoCorte) {
const resSync = await sincronizarPecaKitNoCorte({
ofNumber: insertData.of_number,
pecaId: insertData.peca_id,
processoCorteId: insertData.processo_id,
userId: user.id,
usuarioNome: user.name || user.username || user.email?.split('@')[0] || 'Operador',
dataApontamento: insertData.data_apontamento
});
if (resSync && resSync.delta_liberado > 0) {
toast.success(`Kit 100% cortado! +${resSync.delta_liberado} un da peça ${resSync.marca} liberada(s) para Solda (${resSync.kits_completos}/${resSync.quantidade_total}).`);
}
}
} catch (kitError) {
console.error('Erro ao verificar regra de kit completo:', kitError);
}
}
console.log('✅ Apontamento criado com sucesso, forçando atualização completa...'); console.log('✅ Apontamento criado com sucesso, forçando atualização completa...');
// FORÇA ATUALIZAÇÃO COMPLETA DOS DADOS // FORÇA ATUALIZAÇÃO COMPLETA DOS DADOS
@@ -507,6 +538,38 @@ interface InsertApontamentoData {
} }
}, [user, fetchApontamentos, fetchProcessos]); }, [user, fetchApontamentos, fetchProcessos]);
const sincronizarKitsOF = useCallback(async (ofNumber: string, processoCorteId?: string) => {
try {
const procCorte = processoCorteId || processos.find(p => {
const n = (p.nome || '').toLowerCase();
return n.includes('corte') || n.includes('prepar') || p.ordem === 1;
})?.id;
if (!procCorte) {
console.warn('Processo de corte não encontrado para sincronização de kits.');
return [];
}
const resultados = await sincronizarTodosKitsOFCorte({
ofNumber,
processoCorteId: procCorte,
userId: user?.id,
usuarioNome: user?.name || user?.username || user?.email?.split('@')[0] || 'Operador'
});
const totalLiberadas = resultados.reduce((sum, r) => sum + r.delta_liberado, 0);
if (totalLiberadas > 0) {
toast.success(`Sincronização: +${totalLiberadas} kit(s) completos liberados para Solda!`);
await fetchApontamentos();
}
return resultados;
} catch (e) {
console.error('Erro ao sincronizar kits da OF:', e);
return [];
}
}, [processos, user, fetchApontamentos]);
return { return {
apontamentos, apontamentos,
processos, processos,
@@ -516,6 +579,7 @@ interface InsertApontamentoData {
atualizarProcesso, atualizarProcesso,
exportarModeloCSV, exportarModeloCSV,
buscarQuantidadeProcessada, buscarQuantidadeProcessada,
sincronizarKitsOF,
refetch refetch
}; };
}; };
+22 -5
View File
@@ -24,16 +24,33 @@ import {
// Sincroniza user Logto com profiles Supabase e retorna o UUID real do Supabase // Sincroniza user Logto com profiles Supabase e retorna o UUID real do Supabase
async function syncUserToProfile(user: LogtoUser): Promise<string> { async function syncUserToProfile(user: LogtoUser): Promise<string> {
if (!user?.sub) return user.sub; if (!user?.sub) return user.sub;
const email = user.email || `${user.username || 'user'}@logto.local`; const email = (user.email || `${user.username || 'user'}@logto.local`).trim();
try { try {
// 1. Tenta buscar o perfil existente pelo email // 1. Tenta buscar o perfil existente pelo email (case-insensitive)
const { data: existing } = await supabase let { data: existing, error: selectError } = await supabase
.from('profiles') .from('profiles')
.select('id') .select('id')
.eq('email', email) .ilike('email', email)
.maybeSingle(); .maybeSingle();
// Em caso de falha transitória de rede, faz 1 retry rápido antes de desistir
if (selectError) {
await new Promise((r) => setTimeout(r, 800));
const retry = await supabase
.from('profiles')
.select('id')
.ilike('email', email)
.maybeSingle();
existing = retry.data;
selectError = retry.error;
}
if (selectError) {
console.warn('Erro de rede ao consultar profile no Supabase:', selectError);
return user.sub;
}
let profileId = existing?.id; let profileId = existing?.id;
// 2. Se existe, atualizamos o nome e retornamos o UUID // 2. Se existe, atualizamos o nome e retornamos o UUID
@@ -47,7 +64,7 @@ async function syncUserToProfile(user: LogtoUser): Promise<string> {
return profileId; return profileId;
} }
// 3. Se não existir, criamos um novo com um UUID gerado // 3. Se confirmadamente não existir, criamos um novo com um UUID gerado
profileId = crypto.randomUUID(); profileId = crypto.randomUUID();
const { error: insertError } = await supabase.from('profiles').insert({ const { error: insertError } = await supabase.from('profiles').insert({
id: profileId, id: profileId,
+35 -6
View File
@@ -3,6 +3,14 @@ import { useState, useEffect, useMemo } from 'react';
import { useComponentesPeca } from '@/hooks/useComponentesPeca'; import { useComponentesPeca } from '@/hooks/useComponentesPeca';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
export interface ComponenteVinculoDetalhe {
componente_id: string;
peca_id: string;
peca_marca: string;
quantidade_por_peca: number;
quantidade_necessaria: number;
}
export interface ComponenteAgrupado { export interface ComponenteAgrupado {
marca_componente: string; marca_componente: string;
descricao: string; descricao: string;
@@ -12,6 +20,7 @@ export interface ComponenteAgrupado {
peca_pai_info: string; peca_pai_info: string;
componente_ids: string[]; componente_ids: string[];
peca_ids: string[]; peca_ids: string[];
vinculos: ComponenteVinculoDetalhe[];
} }
// Cache local para evitar buscas repetitivas // Cache local para evitar buscas repetitivas
@@ -36,7 +45,7 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
peca && peca &&
peca.of_number === debouncedOfNumber && peca.of_number === debouncedOfNumber &&
peca.etapa_fase === debouncedFase && peca.etapa_fase === debouncedFase &&
peca.tem_componentes === true // Apenas peças com componentes peca.tem_componentes !== false // Peças com componentes (ou não marcadas como S/M)
); );
}, [pecasData, debouncedOfNumber, debouncedFase]); }, [pecasData, debouncedOfNumber, debouncedFase]);
@@ -101,22 +110,40 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
if (!pecaPai) return; if (!pecaPai) return;
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const qtdTotalPeca = Number(pecaPai.quantidade) || 0;
const qtdNecessaria = porPeca * qtdTotalPeca;
const vinculo: ComponenteVinculoDetalhe = {
componente_id: comp.id,
peca_id: comp.peca_id,
peca_marca: pecaPai.marca,
quantidade_por_peca: porPeca,
quantidade_necessaria: qtdNecessaria,
};
if (grupos.has(marca)) { if (grupos.has(marca)) {
const grupo = grupos.get(marca)!; const grupo = grupos.get(marca)!;
grupo.quantidade_total += comp.quantidade_por_peca * (pecaPai.quantidade || 0); grupo.quantidade_total += qtdNecessaria;
grupo.peca_pai_info += `, ${pecaPai.marca}`; if (!grupo.peca_pai_info.includes(pecaPai.marca)) {
grupo.peca_pai_info += `, ${pecaPai.marca}`;
}
grupo.componente_ids.push(comp.id); grupo.componente_ids.push(comp.id);
grupo.peca_ids.push(comp.peca_id); if (!grupo.peca_ids.includes(comp.peca_id)) {
grupo.peca_ids.push(comp.peca_id);
}
grupo.vinculos.push(vinculo);
} else { } else {
grupos.set(marca, { grupos.set(marca, {
marca_componente: marca, marca_componente: marca,
descricao: comp.descricao || '', descricao: comp.descricao || '',
perfil: comp.perfil || '', perfil: comp.perfil || '',
peso_unitario: comp.peso_unitario, peso_unitario: comp.peso_unitario,
quantidade_total: comp.quantidade_por_peca * (pecaPai.quantidade || 0), quantidade_total: qtdNecessaria,
peca_pai_info: pecaPai.marca, peca_pai_info: pecaPai.marca,
componente_ids: [comp.id], componente_ids: [comp.id],
peca_ids: [comp.peca_id] peca_ids: [comp.peca_id],
vinculos: [vinculo],
}); });
} }
}); });
@@ -133,6 +160,8 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
return { return {
componentesAgrupados, componentesAgrupados,
componentesRaw: componentes || [],
loading: loading || componentesLoading loading: loading || componentesLoading
}; };
}; };
+12 -27
View File
@@ -171,16 +171,11 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
const processoCorteId = processoCorte.data?.id; const processoCorteId = processoCorte.data?.id;
const pecasInfoMap = new Map((pecasData || []).map(p => [p.id, p])); const pecasInfoMap = new Map((pecasData || []).map(p => [p.id, p]));
// 1. Somar peças com componentes apontadas na Solda // 1. Somar peças com componentes apontadas na Solda (apenas tipo peca)
if (processoSoldaId) { if (processoSoldaId) {
const apontamentosSolda = apontamentosData.filter(a => a.processo_id === processoSoldaId); const apontamentosSolda = apontamentosData.filter(a => a.processo_id === processoSoldaId && a.tipo_apontamento === 'peca');
apontamentosSolda.forEach(apontamento => { apontamentosSolda.forEach(apontamento => {
let pesoUnitario = 0; const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
pesoUnitario = Number(apontamento.componente.peso_unitario);
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
pesoUnitario = Number(apontamento.peca.peso_unitario);
}
const pesoApontamento = pesoUnitario * Number(apontamento.quantidade_produzida || 0); const pesoApontamento = pesoUnitario * Number(apontamento.quantidade_produzida || 0);
pesoTotalFabricado += pesoApontamento; pesoTotalFabricado += pesoApontamento;
}); });
@@ -357,15 +352,10 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
let pesoFabricadoProcesso = 0; let pesoFabricadoProcesso = 0;
apontamentosProcesso.forEach(a => { apontamentosProcesso.forEach(a => {
let pesoUnitario = 0; // Apenas apontamentos de peça mestre somam peso no processo para evitar dupla contagem com subcomponentes
if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
if (a.tipo_apontamento === 'componente' && a.componente?.peso_unitario) { pesoFabricadoProcesso += Number(a.peca.peso_unitario) * Number(a.quantidade_produzida);
pesoUnitario = Number(a.componente.peso_unitario);
} else if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
pesoUnitario = Number(a.peca.peso_unitario);
} }
pesoFabricadoProcesso += pesoUnitario * Number(a.quantidade_produzida);
}); });
const isProcessoSolda = processo.nome.toLowerCase().includes('solda'); const isProcessoSolda = processo.nome.toLowerCase().includes('solda');
@@ -507,17 +497,12 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
return dataAptStr <= dataStr; return dataAptStr <= dataStr;
}); });
let realizado = 0; let realizado = 0;
apontamentosAteData.forEach(a => { apontamentosAteData.forEach(a => {
let pesoUnitario = 0; // Apenas peças mestres computam peso realizado no gráfico
if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
if (a.tipo_apontamento === 'componente' && a.componente?.peso_unitario) { realizado += Number(a.peca.peso_unitario) * Number(a.quantidade_produzida);
pesoUnitario = Number(a.componente.peso_unitario); }
} else if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) { });
pesoUnitario = Number(a.peca.peso_unitario);
}
realizado += pesoUnitario * Number(a.quantidade_produzida);
});
dadosGrafico.push({ dadosGrafico.push({
data: dataStr, data: dataStr,
+233
View File
@@ -0,0 +1,233 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { GRUPOS_AVANCADOS_CONFIG, GrupoAvancadoConfig } from '@/types/grupoAvancadoTypes';
export interface UserGrupoAvancado {
userId: string;
email: string;
fullName: string;
functionName: string;
privilegeName: string;
profileImageUrl: string | null;
regras: Record<string, boolean>; // regraKey -> true/false
}
export function useGrupoAvancadoPermissions(grupoKey: string | null) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState<UserGrupoAvancado[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const config: GrupoAvancadoConfig | undefined = grupoKey
? GRUPOS_AVANCADOS_CONFIG[grupoKey]
: undefined;
const carregarDados = useCallback(async () => {
if (!config) return;
try {
setLoading(true);
// 1. Carregar profiles com cargo e privilégio
const { data: profiles, error: profError } = await supabase
.from('profiles')
.select(`
id,
email,
full_name,
profile_image_url,
privileges:privilege_id (
id,
name
),
user_functions:function_id (
id,
name
)
`)
.order('full_name', { ascending: true });
if (profError) throw profError;
// 2. Carregar permissões das regras deste grupo na tabela user_interface_permissions
const chavesRegras = config.regras.map((r) => r.key);
const { data: permsData, error: permsError } = await supabase
.from('user_interface_permissions')
.select('user_id, resource_key, permission')
.in('resource_key', chavesRegras);
if (permsError) {
console.warn('Aviso ao carregar permissões avançadas:', permsError);
}
// Indexar permissões por user_id -> resource_key -> ativo
const userPermsMap: Record<string, Record<string, boolean>> = {};
(permsData || []).forEach((p: any) => {
if (!userPermsMap[p.user_id]) userPermsMap[p.user_id] = {};
userPermsMap[p.user_id][p.resource_key] =
p.permission === 'can_create_update_delete' ||
p.permission === 'can_admin' ||
p.permission === 'can_create_only' ||
p.permission === 'can_view_only';
});
// Mapear colaboradores
const mapped: UserGrupoAvancado[] = (profiles || []).map((p: any) => {
const privName = p.privileges?.name || 'Padrão';
const isAdmin = privName.toLowerCase().includes('admin');
const userRules: Record<string, boolean> = {};
config.regras.forEach((r) => {
if (userPermsMap[p.id] && userPermsMap[p.id][r.key] !== undefined) {
userRules[r.key] = userPermsMap[p.id][r.key];
} else if (isAdmin) {
userRules[r.key] = true;
} else {
userRules[r.key] = Boolean(r.padraoAtivo);
}
});
return {
userId: p.id,
email: p.email || '',
fullName: p.full_name || p.email?.split('@')[0] || 'Usuário',
functionName: p.user_functions?.name || 'Sem Cargo',
privilegeName: privName,
profileImageUrl: p.profile_image_url,
regras: userRules,
};
});
setUsers(mapped);
if (!selectedUserId && mapped.length > 0) {
setSelectedUserId(mapped[0].userId);
}
} catch (err: any) {
console.error('Erro ao carregar permissões avançadas do grupo:', err);
toast.error('Erro ao carregar governança avançada.');
} finally {
setLoading(false);
}
}, [config, selectedUserId]);
useEffect(() => {
if (grupoKey) {
carregarDados();
}
}, [grupoKey]);
const selectedUser = useMemo(
() => users.find((u) => u.userId === selectedUserId) || null,
[users, selectedUserId]
);
// Toggle de uma regra específica para o usuário selecionado
const toggleRegra = (regraKey: string) => {
if (!selectedUserId) return;
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== selectedUserId) return u;
return {
...u,
regras: {
...u.regras,
[regraKey]: !u.regras[regraKey],
},
};
})
);
};
// Aplica um template pré-definido para o usuário selecionado
const aplicarTemplate = (templateId: string) => {
if (!config || !selectedUserId) return;
const tpl = config.templates.find((t) => t.id === templateId);
if (!tpl) return;
const novasRegras: Record<string, boolean> = {};
config.regras.forEach((r) => {
novasRegras[r.key] = tpl.regrasAtivas.includes(r.key);
});
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== selectedUserId) return u;
return {
...u,
regras: novasRegras,
};
})
);
toast.info(`Template "${tpl.nome}" aplicado. Clique em Salvar para gravar.`);
};
// Salvar alterações no banco de dados
const salvarConfiguracaoUsuario = async () => {
if (!config || !selectedUser) return;
try {
setSaving(true);
// Upsert para cada regra na tabela user_interface_permissions
const promises = config.regras.map(async (r) => {
const estaAtivo = Boolean(selectedUser.regras[r.key]);
const permission = estaAtivo ? 'can_create_update_delete' : 'no_access';
// Garantir que a chave exista em interface_resources para evitar erro de Foreign Key
await supabase
.from('interface_resources')
.upsert(
{
resource_key: r.key,
resource_name: r.nome,
is_submenu: false,
},
{ onConflict: 'resource_key' }
);
return supabase
.from('user_interface_permissions')
.upsert(
{
user_id: selectedUser.userId,
resource_key: r.key,
permission: permission as any,
updated_at: new Date().toISOString(),
},
{ onConflict: 'user_id,resource_key' }
);
});
const results = await Promise.all(promises);
const hasError = results.some((res) => res.error);
if (hasError) {
throw new Error('Falha ao persistir algumas regras no banco.');
}
toast.success(`Configurações avançadas salvas para ${selectedUser.fullName}!`);
} catch (err: any) {
console.error('Erro ao salvar regras avançadas:', err);
toast.error('Erro ao salvar alterações no banco.');
} finally {
setSaving(false);
}
};
return {
config,
loading,
saving,
users,
selectedUserId,
setSelectedUserId,
selectedUser,
toggleRegra,
aplicarTemplate,
salvarConfiguracaoUsuario,
recarregar: carregarDados,
};
}
+502
View File
@@ -0,0 +1,502 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
export interface ComponenteKitInfo {
id: string;
peca_id: string;
marca_componente: string;
descricao?: string | null;
perfil?: string | null;
peso_unitario: number;
quantidade_por_peca: number;
quantidade_total_necessaria: number;
quantidade_cortada: number;
kits_possiveis: number;
percentual_cortado: number;
falta_para_prox_kit: number;
concluido: boolean;
}
export interface PecaKitInfo {
peca_id: string;
marca: string;
descricao?: string | null;
perfil_principal?: string | null;
quantidade_total: number;
etapa_fase: string;
tem_componentes: boolean;
peso_unitario: number;
peso_total_peca: number;
peso_kits_liberados: number;
componentes: ComponenteKitInfo[];
kits_completos_cortados: number; // Quantidade de kits 100% montáveis liberados
percentual_termico: number; // 0 a 100%
status_termico: 'verde' | 'verde_parcial' | 'amarelo' | 'vermelho';
componente_gargalo: ComponenteKitInfo | null;
peso_total_componentes: number;
peso_cortado_componentes: number;
ja_apontado_solda: number;
saldo_disponivel_solda: number;
bloqueado_para_solda: boolean;
motivo_bloqueio_solda: string | null;
}
export interface ResumoKitsOF {
totalPecas: number;
totalKits100Porcento: number;
totalKitsParciais: number;
totalKitsNaoIniciados: number;
pesoTotalOFKg: number;
pesoCortadoTotalKg: number;
peso100PorcentoKg: number;
pesoUnidadesLiberadasKg: number;
pesoParciaisKg: number;
pesoNaoIniciadosKg: number;
percentualGeralOF: number;
totalUnidadesTotal: number;
totalUnidadesLiberadasSolda: number;
}
/**
* Função pura para cálculo determinístico de Kits de Produção.
* Recebe peças, componentes e apontamentos e retorna o mapa térmico de kits.
*/
export const calcularKitsOF = (
pecas: Array<{
id: string;
marca: string;
descricao?: string | null;
perfil_principal?: string | null;
quantidade: number;
etapa_fase?: string | null;
tem_componentes?: boolean | null;
peso_unitario?: number | null;
peso_total?: number | null;
}>,
componentes: Array<{
id: string;
peca_id: string;
marca_componente: string;
descricao?: string | null;
perfil?: string | null;
peso_unitario?: number | null;
quantidade_por_peca?: number | null;
}>,
apontamentos: Array<{
id?: string;
peca_id?: string | null;
componente_id?: string | null;
processo_id: string;
quantidade_produzida: number;
tipo_apontamento?: string | null;
}>,
processoCorteId?: string,
processoSoldaId?: string
): Map<string, PecaKitInfo> => {
const mapaKits = new Map<string, PecaKitInfo>();
// 1. Mapear componentes por peca_id
const componentesPorPeca = new Map<string, typeof componentes>();
componentes.forEach((c) => {
if (!componentesPorPeca.has(c.peca_id)) {
componentesPorPeca.set(c.peca_id, []);
}
componentesPorPeca.get(c.peca_id)!.push(c);
});
// 2. Mapear apontamentos de corte por componente_id
const cortadoPorComponente = new Map<string, number>();
// Apontamentos de corte por peca_id (caso seja peça sem componentes ou apontamento direto)
const cortadoPecaNoCorte = new Map<string, number>();
// Apontamentos de solda por peca_id
const apontadoNaSoldaPorPeca = new Map<string, number>();
apontamentos.forEach((ap) => {
const qtd = Number(ap.quantidade_produzida || 0);
// Se for apontamento de componente no corte (ou se não especificou processoCorteId, computa todos de componente)
if (ap.tipo_apontamento === 'componente' && ap.componente_id) {
if (!processoCorteId || ap.processo_id === processoCorteId) {
cortadoPorComponente.set(
ap.componente_id,
(cortadoPorComponente.get(ap.componente_id) || 0) + qtd
);
}
}
// Se for apontamento de peça mestre no corte
if (
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
!ap.componente_id &&
ap.peca_id
) {
if (!processoCorteId || ap.processo_id === processoCorteId) {
cortadoPecaNoCorte.set(
ap.peca_id,
(cortadoPecaNoCorte.get(ap.peca_id) || 0) + qtd
);
}
}
// Se for apontamento de solda
if (
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
!ap.componente_id &&
ap.peca_id
) {
if (processoSoldaId && ap.processo_id === processoSoldaId) {
apontadoNaSoldaPorPeca.set(
ap.peca_id,
(apontadoNaSoldaPorPeca.get(ap.peca_id) || 0) + qtd
);
}
}
});
// 3. Processar cada peça
pecas.forEach((peca) => {
const compsRaw = componentesPorPeca.get(peca.id) || [];
const qtdTotalPeca = Number(peca.quantidade) || 0;
const fase = peca.etapa_fase || 'Geral';
const temComps = peca.tem_componentes !== false && compsRaw.length > 0;
const jaApontadoSolda = apontadoNaSoldaPorPeca.get(peca.id) || 0;
const pesoUnitPeca = Number(peca.peso_unitario) || (qtdTotalPeca > 0 && peca.peso_total ? Number(peca.peso_total) / qtdTotalPeca : 0);
const pesoTotalPeca = Number(peca.peso_total) || (qtdTotalPeca * pesoUnitPeca);
// Cenário A: Peça sem componentes (monobloco / S/M)
if (!temComps) {
const qtdCortada = cortadoPecaNoCorte.get(peca.id) || 0;
const kitsCompletos = Math.min(qtdTotalPeca, qtdCortada);
const pct = qtdTotalPeca > 0 ? Math.min(100, Math.round((kitsCompletos / qtdTotalPeca) * 100)) : 0;
const saldoSolda = Math.max(0, kitsCompletos - jaApontadoSolda);
const pesoCortado = kitsCompletos * pesoUnitPeca;
const pesoLiberado = kitsCompletos * pesoUnitPeca;
mapaKits.set(peca.id, {
peca_id: peca.id,
marca: peca.marca,
descricao: peca.descricao,
perfil_principal: peca.perfil_principal,
quantidade_total: qtdTotalPeca,
etapa_fase: fase,
tem_componentes: false,
peso_unitario: pesoUnitPeca,
peso_total_peca: pesoTotalPeca,
peso_kits_liberados: pesoLiberado,
componentes: [],
kits_completos_cortados: kitsCompletos,
percentual_termico: pct,
status_termico:
kitsCompletos >= qtdTotalPeca
? 'verde'
: kitsCompletos > 0
? 'verde_parcial'
: pct >= 40
? 'amarelo'
: 'vermelho',
componente_gargalo: null,
peso_total_componentes: pesoTotalPeca,
peso_cortado_componentes: pesoCortado,
ja_apontado_solda: jaApontadoSolda,
saldo_disponivel_solda: peca.tem_componentes === false ? 0 : saldoSolda,
bloqueado_para_solda: peca.tem_componentes === false || saldoSolda <= 0,
motivo_bloqueio_solda:
peca.tem_componentes === false
? 'Peça S/M (Pula Solda)'
: saldoSolda <= 0 && kitsCompletos === 0
? 'Aguardando corte da peça'
: saldoSolda <= 0
? `Todos os ${kitsCompletos} kits cortados já foram soldados`
: null,
});
return;
}
// Cenário B: Peça com componentes
let pesoTotalComps = 0;
let pesoCortadoComps = 0;
let menorKits = qtdTotalPeca;
let gargaloComp: ComponenteKitInfo | null = null;
let menorRazao = 999999;
const listaCompsInfo: ComponenteKitInfo[] = [];
compsRaw.forEach((comp) => {
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const totalNecessario = qtdTotalPeca * porPeca;
const cortado = cortadoPorComponente.get(comp.id) || 0;
const pesoUnit = Number(comp.peso_unitario) || 0;
const kitsDesteComp = Math.floor(cortado / porPeca);
if (kitsDesteComp < menorKits) {
menorKits = kitsDesteComp;
}
const pctComp = totalNecessario > 0 ? Math.min(100, Math.round((cortado / totalNecessario) * 100)) : 0;
const pesoTotalComp = totalNecessario * pesoUnit;
const pesoCortadoComp = Math.min(cortado, totalNecessario) * pesoUnit;
pesoTotalComps += pesoTotalComp;
pesoCortadoComps += pesoCortadoComp;
const infoComp: ComponenteKitInfo = {
id: comp.id,
peca_id: comp.peca_id,
marca_componente: comp.marca_componente,
descricao: comp.descricao,
perfil: comp.perfil,
peso_unitario: pesoUnit,
quantidade_por_peca: porPeca,
quantidade_total_necessaria: totalNecessario,
quantidade_cortada: cortado,
kits_possiveis: kitsDesteComp,
percentual_cortado: pctComp,
falta_para_prox_kit: 0, // calculado logo abaixo
concluido: cortado >= totalNecessario,
};
listaCompsInfo.push(infoComp);
});
const kitsCompletos = Math.min(qtdTotalPeca, Math.max(0, menorKits));
// Determinar o componente gargalo para o próximo kit
listaCompsInfo.forEach((ci) => {
const necessarioParaProxKit = Math.min(qtdTotalPeca, kitsCompletos + 1) * ci.quantidade_por_peca;
ci.falta_para_prox_kit = Math.max(0, necessarioParaProxKit - ci.quantidade_cortada);
// Critério de gargalo: quem mais segura a formação de novos kits
const razao = ci.kits_possiveis;
if (razao < menorRazao && kitsCompletos < qtdTotalPeca) {
menorRazao = razao;
gargaloComp = ci;
}
});
// Percentual térmico de prontidão ponderado por peso (ou por qtd se peso=0)
let percentualTermico = 0;
if (pesoTotalComps > 0) {
percentualTermico = Math.min(100, Math.round((pesoCortadoComps / pesoTotalComps) * 100));
} else {
const qtdTotalNecessaria = listaCompsInfo.reduce((s, c) => s + c.quantidade_total_necessaria, 0);
const qtdTotalCortada = listaCompsInfo.reduce((s, c) => s + Math.min(c.quantidade_cortada, c.quantidade_total_necessaria), 0);
percentualTermico = qtdTotalNecessaria > 0 ? Math.min(100, Math.round((qtdTotalCortada / qtdTotalNecessaria) * 100)) : 0;
}
// Status térmico
let statusTermico: 'verde' | 'verde_parcial' | 'amarelo' | 'vermelho' = 'vermelho';
if (kitsCompletos >= qtdTotalPeca) {
statusTermico = 'verde';
} else if (kitsCompletos > 0) {
statusTermico = 'verde_parcial';
} else if (percentualTermico >= 40) {
statusTermico = 'amarelo';
} else {
statusTermico = 'vermelho';
}
const saldoSolda = Math.max(0, kitsCompletos - jaApontadoSolda);
const bloqueadoSolda = saldoSolda <= 0;
let motivoBloqueioSolda: string | null = null;
if (bloqueadoSolda) {
if (kitsCompletos === 0) {
const detGargalo = gargaloComp ? ` • Gargalo: ${gargaloComp.marca_componente} (faltam ${gargaloComp.falta_para_prox_kit} un)` : '';
motivoBloqueioSolda = `Aguardando kits completos de corte (0 un liberadas)${detGargalo}`;
} else if (jaApontadoSolda >= kitsCompletos) {
motivoBloqueioSolda = `Todos os ${kitsCompletos} kits cortados já foram soldados (${jaApontadoSolda}/${qtdTotalPeca} un) • Aguardando novos kits cortados`;
}
}
const pesoUnitComps = pesoTotalComps > 0 && qtdTotalPeca > 0 ? pesoTotalComps / qtdTotalPeca : 0;
const pesoUnitPecaFinal = Number(peca.peso_unitario) || pesoUnitComps;
const pesoTotalPecaFinal = pesoTotalComps > 0 ? pesoTotalComps : (Number(peca.peso_total) || (qtdTotalPeca * pesoUnitPecaFinal));
const pesoLiberadoFinal = kitsCompletos * pesoUnitPecaFinal;
mapaKits.set(peca.id, {
peca_id: peca.id,
marca: peca.marca,
descricao: peca.descricao,
perfil_principal: peca.perfil_principal,
quantidade_total: qtdTotalPeca,
etapa_fase: fase,
tem_componentes: true,
peso_unitario: pesoUnitPecaFinal,
peso_total_peca: pesoTotalPecaFinal,
peso_kits_liberados: pesoLiberadoFinal,
componentes: listaCompsInfo,
kits_completos_cortados: kitsCompletos,
percentual_termico: percentualTermico,
status_termico: statusTermico,
componente_gargalo: gargaloComp,
peso_total_componentes: pesoTotalPecaFinal,
peso_cortado_componentes: pesoCortadoComps,
ja_apontado_solda: jaApontadoSolda,
saldo_disponivel_solda: saldoSolda,
bloqueado_para_solda: bloqueadoSolda,
motivo_bloqueio_solda: motivoBloqueioSolda,
});
});
return mapaKits;
};
/**
* Hook completo para carregar e gerenciar os Kits de uma OF e Fase.
*/
export const useKitsProducao = (ofNumber?: string, fase?: string) => {
const { data, isLoading, refetch, error } = useQuery({
queryKey: ['kits-producao', ofNumber, fase],
enabled: Boolean(ofNumber),
staleTime: 10000, // 10 segundos
queryFn: async () => {
if (!ofNumber) return { mapaKits: new Map<string, PecaKitInfo>(), resumo: null, lista: [] };
// 1. Buscar processos para identificar IDs de Corte e Solda
const { data: procs } = await supabase
.from('processos_fabricacao')
.select('id, nome, ordem')
.order('ordem');
const procCorte = (procs || []).find(
(p) => p.nome.toLowerCase().includes('corte') || p.nome.toLowerCase().includes('prepar') || p.ordem === 1
);
const procSolda = (procs || []).find(
(p) => p.nome.toLowerCase().includes('solda') || p.nome.toLowerCase().includes('montag') || p.ordem === 3
);
// 2. Buscar peças da OF
let pecasQuery = supabase
.from('pecas')
.select('id, marca, descricao, perfil_principal, quantidade, etapa_fase, tem_componentes, peso_unitario, peso_total')
.eq('of_number', ofNumber);
if (fase && fase !== 'all' && fase !== 'Todas as fases' && fase !== 'todas') {
pecasQuery = pecasQuery.eq('etapa_fase', fase);
}
const { data: pecasData, error: pecasErr } = await pecasQuery;
if (pecasErr) throw pecasErr;
const pecas = pecasData || [];
const pecaIds = pecas.map((p) => p.id);
// 3. Buscar componentes das peças
let componentes: Array<{
id: string;
peca_id: string;
marca_componente: string;
descricao?: string | null;
perfil?: string | null;
peso_unitario?: number | null;
quantidade_por_peca?: number | null;
}> = [];
if (pecaIds.length > 0) {
for (let i = 0; i < pecaIds.length; i += 300) {
const chunk = pecaIds.slice(i, i + 300);
const { data: compsChunk, error: compsErr } = await supabase
.from('componentes_peca')
.select('id, peca_id, marca_componente, descricao, perfil, peso_unitario, quantidade_por_peca')
.in('peca_id', chunk);
if (compsErr) throw compsErr;
if (compsChunk) {
componentes = [...componentes, ...compsChunk];
}
}
}
// 4. Buscar apontamentos da OF
const { data: apontamentosData, error: aptsErr } = await supabase
.from('apontamentos_producao')
.select('id, peca_id, componente_id, processo_id, quantidade_produzida, tipo_apontamento')
.eq('of_number', ofNumber);
if (aptsErr) throw aptsErr;
// 5. Calcular kits
const mapaKits = calcularKitsOF(
pecas,
componentes,
apontamentosData || [],
procCorte?.id,
procSolda?.id
);
const lista = Array.from(mapaKits.values()).sort((a, b) =>
a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' })
);
// 6. Resumo geral
let total100 = 0;
let totalParciais = 0;
let totalTravados = 0;
let pesoTot = 0;
let pesoCort = 0;
let peso100 = 0;
let pesoUnLiberadas = 0;
let pesoParciais = 0;
let pesoNaoIniciados = 0;
let unTot = 0;
let unLib = 0;
lista.forEach((item) => {
unTot += item.quantidade_total;
unLib += item.kits_completos_cortados;
pesoTot += item.peso_total_peca;
pesoCort += item.peso_cortado_componentes;
pesoUnLiberadas += item.peso_kits_liberados;
if (item.status_termico === 'verde') {
total100++;
peso100 += item.peso_total_peca;
} else if (item.status_termico === 'verde_parcial' || item.status_termico === 'amarelo') {
totalParciais++;
pesoParciais += item.peso_total_peca;
} else {
totalTravados++;
pesoNaoIniciados += item.peso_total_peca;
}
});
const resumo: ResumoKitsOF = {
totalPecas: lista.length,
totalKits100Porcento: total100,
totalKitsParciais: totalParciais,
totalKitsNaoIniciados: totalTravados,
pesoTotalOFKg: pesoTot,
pesoCortadoTotalKg: pesoCort,
peso100PorcentoKg: peso100,
pesoUnidadesLiberadasKg: pesoUnLiberadas,
pesoParciaisKg: pesoParciais,
pesoNaoIniciadosKg: pesoNaoIniciados,
percentualGeralOF: pesoTot > 0 ? Math.round((pesoCortadoTotalKg(pesoTot, pesoCort)) * 100) : 0,
totalUnidadesTotal: unTot,
totalUnidadesLiberadasSolda: unLib,
};
return {
mapaKits,
resumo,
lista,
};
},
});
return {
kitsPorPeca: data?.mapaKits || new Map<string, PecaKitInfo>(),
listaKits: data?.lista || [],
resumoKits: data?.resumo,
loading: isLoading,
refetch,
error,
};
};
function pesoCortadoTotalKg(total: number, cortado: number): number {
if (total <= 0) return 0;
return Math.min(1, cortado / total);
}
+1
View File
@@ -63,6 +63,7 @@ export const GRUPOS_MODULOS: GrupoRecurso[] = [
descricao: 'Projetos, modelos 3D, fichas técnicas e cronogramas', descricao: 'Projetos, modelos 3D, fichas técnicas e cronogramas',
recursos: [ recursos: [
{ key: 'visualizador-3d', nome: 'Visualizador 3D BIM', rota: '/visualizador-3d' }, { key: 'visualizador-3d', nome: 'Visualizador 3D BIM', rota: '/visualizador-3d' },
{ key: 'analisador-3d', nome: 'Analisador 3D & QA/QC', rota: '/analisador-3d' },
{ key: 'ofs-lista', nome: 'Ordens de Fabricação', rota: '/ofs' }, { key: 'ofs-lista', nome: 'Ordens de Fabricação', rota: '/ofs' },
{ key: 'ofs-cronograma', nome: 'Cronogramas de Fabricação', rota: '/ofs/cronograma' }, { key: 'ofs-cronograma', nome: 'Cronogramas de Fabricação', rota: '/ofs/cronograma' },
{ key: 'cadastro-pecas', nome: 'Cadastro de Peças & Conjuntos', rota: '/cadastro/pecas' }, { key: 'cadastro-pecas', nome: 'Cadastro de Peças & Conjuntos', rota: '/cadastro/pecas' },
+10 -3
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
export interface Peca { export interface Peca {
id: string; id: string;
@@ -21,6 +22,9 @@ export interface Peca {
user_id: string; user_id: string;
prioridade: string; prioridade: string;
quantidadeDisponivel?: number; quantidadeDisponivel?: number;
revisao_atual?: string;
tem_revisao?: boolean;
qtd_revisoes?: number;
} }
interface PecaPrioridade { interface PecaPrioridade {
@@ -422,6 +426,7 @@ export function usePecas() {
pecasData.forEach((item, index) => { pecasData.forEach((item, index) => {
const pecaKey = `${item.of_number || ''}-${item.etapa_fase || ''}-${item.marca || ''}`; const pecaKey = `${item.of_number || ''}-${item.etapa_fase || ''}-${item.marca || ''}`;
const comprimentoPeca = extrairComprimentoSeguro(item);
const pecaData = { const pecaData = {
of_number: item.of_number || '', of_number: item.of_number || '',
@@ -431,7 +436,7 @@ export function usePecas() {
quantidade: Number(item.quantidade) || 0, quantidade: Number(item.quantidade) || 0,
peso_unitario: Number(item.peso_unitario) || 0, peso_unitario: Number(item.peso_unitario) || 0,
peso_total: Number(item.peso_total) || 0, peso_total: Number(item.peso_total) || 0,
comprimento: item.comprimento ? Number(item.comprimento) : (item.comprimento_ref ? Number(item.comprimento_ref) : null), comprimento: comprimentoPeca,
tratamento_superficial: item.tratamento_superficial || '', tratamento_superficial: item.tratamento_superficial || '',
material: item.material || '', material: item.material || '',
perfil_principal: item.perfil_principal || '', perfil_principal: item.perfil_principal || '',
@@ -442,6 +447,8 @@ export function usePecas() {
if (!pecasMap.has(pecaKey)) { if (!pecasMap.has(pecaKey)) {
pecasMap.set(pecaKey, pecaData); pecasMap.set(pecaKey, pecaData);
} else if (comprimentoPeca && !pecasMap.get(pecaKey).comprimento) {
pecasMap.get(pecaKey).comprimento = comprimentoPeca;
} }
if (item.marca_componente && String(item.marca_componente).trim()) { if (item.marca_componente && String(item.marca_componente).trim()) {
@@ -467,12 +474,13 @@ export function usePecas() {
} }
cleanMarca = String(numVal); cleanMarca = String(numVal);
const compComprimento = extrairComprimentoComponenteSeguro(item);
const componenteData = { const componenteData = {
marca_componente: cleanMarca, marca_componente: cleanMarca,
descricao: item.descricao_componente || item.descricao || '', descricao: item.descricao_componente || item.descricao || '',
perfil: item.perfil_componente || item.perfil_principal || '', perfil: item.perfil_componente || item.perfil_principal || '',
peso_unitario: Number(item.peso_unitario_componente) || 0, peso_unitario: Number(item.peso_unitario_componente) || 0,
comprimento: item.comprimento_componente ? Number(item.comprimento_componente) : null, comprimento: compComprimento,
quantidade_por_peca: Math.max(1, Math.round(Number(item.quantidade_por_peca) || 1)), quantidade_por_peca: Math.max(1, Math.round(Number(item.quantidade_por_peca) || 1)),
user_id: user.id user_id: user.id
}; };
@@ -498,7 +506,6 @@ export function usePecas() {
const { data: pecasExistentes, error: queryExistentesError } = await supabase const { data: pecasExistentes, error: queryExistentesError } = await supabase
.from('pecas') .from('pecas')
.select('id, of_number, etapa_fase, marca') .select('id, of_number, etapa_fase, marca')
.eq('user_id', user.id)
.in('of_number', ofsParaConsultar); .in('of_number', ofsParaConsultar);
if (queryExistentesError) { if (queryExistentesError) {
+27 -1
View File
@@ -2,6 +2,7 @@
import { useUserPermissions } from '@/hooks/useUserPermissions'; import { useUserPermissions } from '@/hooks/useUserPermissions';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import type { ResourceKey, PermissionLevel } from '@/hooks/useUserPermissions/types'; import type { ResourceKey, PermissionLevel } from '@/hooks/useUserPermissions/types';
import { GRUPOS_AVANCADOS_CONFIG } from '@/types/grupoAvancadoTypes';
export function usePermissions() { export function usePermissions() {
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
@@ -68,10 +69,35 @@ export function usePermissions() {
} }
}; };
/**
* Verifica se o colaborador logado tem autorização para executar uma regra avançada específica
* (ex: 'pcp_fechar_diario', 'eng_criar_of', 'exp_despachar_carga', etc.)
*/
const canRegra = (regraKey: string): boolean => {
if (isAdmin) return true;
// 1. Verificar permissão explícita configurada no banco
const perm = getResourcePermission(regraKey);
if (perm === 'no_access') return false;
if (perm) return true;
// 2. Fallback: verificar se a regra é ativa por padrão nas configurações
for (const grupo of Object.values(GRUPOS_AVANCADOS_CONFIG)) {
const regra = grupo.regras.find((r) => r.key === regraKey);
if (regra) {
return Boolean(regra.padraoAtivo);
}
}
return false;
};
return { return {
isAdmin,
checkResourceAccess, checkResourceAccess,
getPermissionForResource, getPermissionForResource,
canPerformAction, canPerformAction,
userPermissions canRegra,
userPermissions,
}; };
} }
+4 -7
View File
@@ -81,14 +81,11 @@ export const useProcessChartData = (ofNumber: string) => {
if (apontamentos && apontamentos.length > 0) { if (apontamentos && apontamentos.length > 0) {
apontamentos.forEach(apontamento => { apontamentos.forEach(apontamento => {
let pesoUnitario = 0; // Apenas peças mestres somam peso nas médias diárias e metas
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) { if (apontamento.tipo_apontamento !== 'peca') return;
pesoUnitario = apontamento.componente.peso_unitario;
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
pesoUnitario = apontamento.peca.peso_unitario;
}
const pesoTotal = pesoUnitario * apontamento.quantidade_produzida; const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
const pesoTotal = pesoUnitario * Number(apontamento.quantidade_produzida);
pesoTotalProcessado += pesoTotal; pesoTotalProcessado += pesoTotal;
if (apontamento.data_apontamento) { if (apontamento.data_apontamento) {
+6 -1
View File
@@ -12,6 +12,7 @@ interface ApontamentoDiario {
descricao: string; descricao: string;
processo: string; processo: string;
quantidade: number; quantidade: number;
tipo_apontamento: 'peca' | 'componente';
peso_unitario: number; peso_unitario: number;
peso_total: number; peso_total: number;
data_apontamento: string; data_apontamento: string;
@@ -105,6 +106,7 @@ export const useRelatorioDiario = (selectedDate: string) => {
descricao, descricao,
processo: apt.processo?.nome || 'N/A', processo: apt.processo?.nome || 'N/A',
quantidade: apt.quantidade_produzida, quantidade: apt.quantidade_produzida,
tipo_apontamento: (apt.tipo_apontamento || 'peca') as 'peca' | 'componente',
peso_unitario, peso_unitario,
peso_total, peso_total,
data_apontamento: apt.data_apontamento, data_apontamento: apt.data_apontamento,
@@ -139,11 +141,14 @@ export const useRelatorioDiario = (selectedDate: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDate]); }, [selectedDate]);
// Calcular resumo por processo // Calcular resumo por processo (apenas peças principais computam quantidade e peso no resumo da produção)
const resumoProcessos: ResumoProcesso[] = React.useMemo(() => { const resumoProcessos: ResumoProcesso[] = React.useMemo(() => {
const resumo = new Map<string, { quantidade: number; apontamentos: number; peso_total: number }>(); const resumo = new Map<string, { quantidade: number; apontamentos: number; peso_total: number }>();
apontamentos.forEach(apt => { apontamentos.forEach(apt => {
// Ignorar componentes no resumo global de produção para evitar peso e contagem duplicados
if (apt.tipo_apontamento !== 'peca') return;
const processo = apt.processo; const processo = apt.processo;
if (!resumo.has(processo)) { if (!resumo.has(processo)) {
resumo.set(processo, { quantidade: 0, apontamentos: 0, peso_total: 0 }); resumo.set(processo, { quantidade: 0, apontamentos: 0, peso_total: 0 });
+397
View File
@@ -0,0 +1,397 @@
import { useState, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { Peca } from '@/hooks/usePecas';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
export type TipoRevisao = 'simples' | 'critica';
export interface Revisao {
id: string;
of_number: string;
peca_id: string;
numero_revisao: string;
tipo_revisao: TipoRevisao;
descricao_motivo: string;
observacoes: string | null;
usuario_id: string | null;
usuario_nome: string | null;
usuario_email: string | null;
data_revisao: string;
dados_anteriores: Record<string, any>;
dados_novos: Record<string, any>;
campos_alterados: string[];
afeta_apontamentos: boolean;
impacto_processos: Record<string, any> | null;
created_at: string;
updated_at: string;
peca?: {
marca: string;
etapa_fase: string;
descricao: string;
};
}
export interface CriarRevisaoDTO {
peca: Peca;
numeroRevisao: string;
tipoRevisao?: TipoRevisao;
descricaoMotivo: string;
observacoes?: string;
novosDados: Partial<Peca>;
}
export function useRevisoes(ofNumber?: string) {
const { user } = useAuth();
const [revisoes, setRevisoes] = useState<Revisao[]>([]);
const [loading, setLoading] = useState(false);
// Carrega todas as revisões de uma OF
const carregarRevisoesOF = useCallback(async (of?: string) => {
const ofAlvo = of || ofNumber;
if (!ofAlvo) return [];
setLoading(true);
try {
const { data, error } = await supabase
.from('revisoes' as any)
.select(`
*,
peca:pecas(marca, etapa_fase, descricao)
`)
.eq('of_number', ofAlvo)
.order('data_revisao', { ascending: false });
if (error) {
console.error('Erro ao buscar revisões da OF:', error);
throw error;
}
const lista = (data || []) as unknown as Revisao[];
setRevisoes(lista);
return lista;
} catch (err: any) {
console.error('Erro ao carregar revisões:', err);
toast.error('Erro ao carregar registros de revisões');
return [];
} finally {
setLoading(false);
}
}, [ofNumber]);
// Carrega histórico de revisões de uma peça específica
const carregarRevisoesPeca = useCallback(async (pecaId: string): Promise<Revisao[]> => {
if (!pecaId) return [];
try {
const { data, error } = await supabase
.from('revisoes' as any)
.select(`
*,
peca:pecas(marca, etapa_fase, descricao)
`)
.eq('peca_id', pecaId)
.order('data_revisao', { ascending: false });
if (error) {
console.error('Erro ao buscar revisões da peça:', error);
throw error;
}
return (data || []) as unknown as Revisao[];
} catch (err: any) {
console.error('Erro ao carregar revisões da peça:', err);
return [];
}
}, []);
// Analisa automaticamente se uma alteração é Crítica ou Simples
const detectarTipoRevisao = (pecaAtual: Peca, novosDados: Partial<Peca>): {
tipo: TipoRevisao;
camposAlterados: string[];
afetaApontamentos: boolean;
detalhes: string[];
} => {
const camposCriticos = ['marca', 'quantidade', 'peso_unitario', 'peso_total', 'tem_componentes'];
const camposAlterados: string[] = [];
const detalhes: string[] = [];
let isCritica = false;
let afetaApontamentos = false;
// Verificar cada campo relevante
const chavesParaChecar = [
'marca',
'quantidade',
'peso_unitario',
'peso_total',
'descricao',
'etapa_fase',
'material',
'perfil_principal',
'tratamento_superficial',
'comprimento',
'tem_componentes'
];
for (const chave of chavesParaChecar) {
const valorAntigo = (pecaAtual as any)[chave];
const valorNovo = (novosDados as any)[chave];
if (valorNovo !== undefined && valorNovo !== null && valorNovo !== valorAntigo) {
// Para números, checar diferença de valor
if (typeof valorAntigo === 'number' && typeof valorNovo === 'number') {
if (Math.abs(valorAntigo - valorNovo) > 0.0001) {
camposAlterados.push(chave);
if (camposCriticos.includes(chave)) {
isCritica = true;
if (chave === 'marca' || chave === 'quantidade') {
afetaApontamentos = true;
}
}
}
} else {
camposAlterados.push(chave);
if (camposCriticos.includes(chave)) {
isCritica = true;
if (chave === 'marca') {
afetaApontamentos = true;
}
}
}
}
}
if (camposAlterados.includes('marca')) {
detalhes.push(`Alteração no código/marca da peça (${pecaAtual.marca} ➔ ${novosDados.marca})`);
}
if (camposAlterados.includes('quantidade')) {
const diff = Number(novosDados.quantidade || 0) - Number(pecaAtual.quantidade || 0);
detalhes.push(`Alteração na quantidade (${pecaAtual.quantidade} ➔ ${novosDados.quantidade}, dif: ${diff > 0 ? '+' : ''}${diff})`);
}
if (camposAlterados.includes('peso_unitario') || camposAlterados.includes('peso_total')) {
detalhes.push(`Alteração dimensional/peso`);
}
return {
tipo: isCritica ? 'critica' : 'simples',
camposAlterados,
afetaApontamentos,
detalhes
};
};
// Cria e aplica a revisão
const criarRevisao = async (dto: CriarRevisaoDTO): Promise<boolean> => {
const { peca, numeroRevisao, descricaoMotivo, observacoes, novosDados } = dto;
if (!dto.descricaoMotivo?.trim()) {
toast.error('Informe o motivo ou observações da revisão');
return false;
}
const analise = detectarTipoRevisao(peca, novosDados);
const tipoFinal = dto.tipoRevisao || analise.tipo;
const camposAlterados = analise.camposAlterados;
if (camposAlterados.length === 0) {
toast.error('Nenhum campo foi alterado nesta peça.');
return false;
}
setLoading(true);
try {
const dadosAnteriores: Record<string, any> = {};
const dadosNovos: Record<string, any> = {};
camposAlterados.forEach(c => {
dadosAnteriores[c] = (peca as any)[c];
dadosNovos[c] = (novosDados as any)[c];
});
const impactoProcessos: Record<string, any> = {
resumo: [],
ajustes_apontamentos: []
};
// REGRA DE NEGÓCIO DE APONTAMENTOS / PROCESSOS:
// Caso 1: A quantidade foi reduzida (pra menos)
const novaQtd = Number(novosDados.quantidade !== undefined ? novosDados.quantidade : peca.quantidade);
const qtdAntiga = Number(peca.quantidade || 0);
if (camposAlterados.includes('quantidade') && novaQtd < qtdAntiga) {
// Buscar todos os apontamentos da peça agrupados por processo
const { data: apontamentos, error: apErr } = await supabase
.from('apontamentos_producao')
.select(`
id,
processo_id,
quantidade_produzida,
data_apontamento,
created_at,
processo:processos_fabricacao(id, nome, ordem)
`)
.eq('peca_id', peca.id)
.eq('tipo_apontamento', 'peca')
.order('created_at', { ascending: false });
if (!apErr && apontamentos && apontamentos.length > 0) {
// Agrupar apontamentos por processo
const porProcesso: Record<string, typeof apontamentos> = {};
apontamentos.forEach(a => {
if (!porProcesso[a.processo_id]) porProcesso[a.processo_id] = [];
porProcesso[a.processo_id].push(a);
});
for (const [procId, listaAps] of Object.entries(porProcesso)) {
const totalProduzido = listaAps.reduce((sum, a) => sum + Number(a.quantidade_produzida || 0), 0);
const nomeProc = (listaAps[0].processo as any)?.nome || procId;
if (totalProduzido > novaQtd) {
let excesso = totalProduzido - novaQtd;
impactoProcessos.resumo.push(
`Processo "${nomeProc}": Produção de ${totalProduzido} un reduzida para o novo teto de ${novaQtd} un (corte de ${excesso} un).`
);
// Reduzir dos apontamentos mais recentes para os mais antigos
for (const ap of listaAps) {
if (excesso <= 0) break;
const apQtd = Number(ap.quantidade_produzida || 0);
if (apQtd <= excesso) {
// Apontamento inteiro é absorvido/removido
await supabase
.from('apontamentos_producao')
.delete()
.eq('id', ap.id);
excesso -= apQtd;
impactoProcessos.ajustes_apontamentos.push({
apontamento_id: ap.id,
processo: nomeProc,
acao: 'removido',
quantidade_anterior: apQtd,
quantidade_nova: 0
});
} else {
// Apontamento parcial
const novaQtdAp = apQtd - excesso;
await supabase
.from('apontamentos_producao')
.update({
quantidade_produzida: novaQtdAp,
observacoes: `${ap.data_apontamento || ''} [Revisado por ${numeroRevisao} - Qtd ajustada de ${apQtd} para ${novaQtdAp}]`
})
.eq('id', ap.id);
impactoProcessos.ajustes_apontamentos.push({
apontamento_id: ap.id,
processo: nomeProc,
acao: 'reduzido',
quantidade_anterior: apQtd,
quantidade_nova: novaQtdAp
});
excesso = 0;
}
}
} else {
impactoProcessos.resumo.push(
`Processo "${nomeProc}": Mantido (${totalProduzido} un já apontadas estão dentro do novo limite de ${novaQtd} un).`
);
}
}
}
} else if (camposAlterados.includes('quantidade') && novaQtd > qtdAntiga) {
// Caso 2: A quantidade foi aumentada (pra mais)
const diferenca = novaQtd - qtdAntiga;
impactoProcessos.resumo.push(
`Aumento de quantidade (+${diferenca} un): O saldo excedente nasce automaticamente no 1º processo da linha de fabricação para novos apontamentos.`
);
}
// Caso 3: Marca alterada
if (camposAlterados.includes('marca')) {
const novaMarca = String(novosDados.marca || '');
impactoProcessos.resumo.push(
`Código da peça alterado de "${peca.marca}" para "${novaMarca}". Vínculos sincronizados.`
);
// Atualizar romaneios que desnormalizam a marca
await supabase
.from('itens_romaneio_pecas')
.update({ marca: novaMarca })
.eq('peca_id', peca.id);
}
// 1. Atualizar a peça no banco de dados
const payloadUpdate: any = {
...novosDados,
updated_at: new Date().toISOString()
};
// Recalcular peso total se peso_unitario ou quantidade mudaram
if (novosDados.quantidade !== undefined || novosDados.peso_unitario !== undefined) {
const qtdFinal = Number(novosDados.quantidade !== undefined ? novosDados.quantidade : peca.quantidade);
const pesoUnitFinal = Number(novosDados.peso_unitario !== undefined ? novosDados.peso_unitario : peca.peso_unitario);
payloadUpdate.peso_total = Number((qtdFinal * pesoUnitFinal).toFixed(3));
}
const { error: updateErr } = await supabase
.from('pecas')
.update(payloadUpdate)
.eq('id', peca.id);
if (updateErr) {
console.error('Erro ao atualizar peça na revisão:', updateErr);
throw updateErr;
}
// 2. Inserir na tabela revisoes
const { error: revErr } = await supabase
.from('revisoes' as any)
.insert({
of_number: peca.of_number,
peca_id: peca.id,
numero_revisao: numeroRevisao.trim(),
tipo_revisao: tipoFinal,
descricao_motivo: descricaoMotivo.trim(),
observacoes: observacoes?.trim() || null,
usuario_id: user?.id || null,
usuario_nome: user?.name || user?.email || 'Administrador',
usuario_email: user?.email || null,
dados_anteriores: dadosAnteriores,
dados_novos: dadosNovos,
campos_alterados: camposAlterados,
afeta_apontamentos: analise.afetaApontamentos,
impacto_processos: impactoProcessos,
data_revisao: new Date().toISOString()
});
if (revErr) {
console.error('Erro ao registrar revisão:', revErr);
throw revErr;
}
toast.success(
`Revisão ${numeroRevisao} (${tipoFinal === 'critica' ? 'Crítica' : 'Simples'}) registrada com sucesso!`
);
// Recarregar revisões da OF
await carregarRevisoesOF(peca.of_number);
return true;
} catch (err: any) {
console.error('Falha ao processar revisão:', err);
toast.error('Erro ao processar revisão: ' + (err.message || 'Erro desconhecido'));
return false;
} finally {
setLoading(false);
}
};
return {
revisoes,
loading,
carregarRevisoesOF,
carregarRevisoesPeca,
detectarTipoRevisao,
criarRevisao
};
}
+15 -11
View File
@@ -128,12 +128,15 @@ export const usePecasPintura = (ofNumber?: string) => {
.from('apontamentos_producao') .from('apontamentos_producao')
.select(` .select(`
peca_id, peca_id,
componente_id,
tipo_apontamento,
quantidade_produzida, quantidade_produzida,
pecas ( pecas (
id, id,
marca, marca,
etapa_fase, etapa_fase,
descricao, descricao,
quantidade,
peso_unitario, peso_unitario,
comprimento, comprimento,
of_number of_number
@@ -151,15 +154,16 @@ export const usePecasPintura = (ofNumber?: string) => {
const { data: itensExpedidos, error: expedidosError } = await supabase const { data: itensExpedidos, error: expedidosError } = await supabase
.from('itens_romaneio_pecas') .from('itens_romaneio_pecas')
.select('peca_id, quantidade_expedida') .select('peca_id, quantidade_expedida')
.in('peca_id', apontamentosPintura?.map(a => a.peca_id) || []); .in('peca_id', apontamentosPintura?.map(a => a.peca_id).filter(Boolean) || []);
if (expedidosError) throw expedidosError; if (expedidosError) throw expedidosError;
// Calcular disponibilidade // Calcular disponibilidade (apenas peças principais, ignorando componentes)
const pecasPintura: PecaPintura[] = []; const pecasPintura: PecaPintura[] = [];
const pecasMap = new Map(); const pecasMap = new Map();
apontamentosPintura?.forEach(apontamento => { apontamentosPintura?.forEach(apontamento => {
if (apontamento.tipo_apontamento === 'componente' || apontamento.componente_id) return;
const pecaId = apontamento.peca_id; const pecaId = apontamento.peca_id;
const peca = apontamento.pecas; const peca = apontamento.pecas;
@@ -173,6 +177,7 @@ export const usePecasPintura = (ofNumber?: string) => {
marca: peca.marca, marca: peca.marca,
etapa_fase: peca.etapa_fase, etapa_fase: peca.etapa_fase,
descricao: peca.descricao, descricao: peca.descricao,
quantidade_total: peca.quantidade,
peso_unitario: peca.peso_unitario, peso_unitario: peca.peso_unitario,
comprimento: peca.comprimento, comprimento: peca.comprimento,
quantidade_produzida_pintura: apontamento.quantidade_produzida, quantidade_produzida_pintura: apontamento.quantidade_produzida,
@@ -186,10 +191,14 @@ export const usePecasPintura = (ofNumber?: string) => {
?.filter(item => item.peca_id === pecaId) ?.filter(item => item.peca_id === pecaId)
.reduce((sum, item) => sum + item.quantidade_expedida, 0) || 0; .reduce((sum, item) => sum + item.quantidade_expedida, 0) || 0;
const qtdProdLimitada = peca.quantidade_total
? Math.min(Number(peca.quantidade_total), peca.quantidade_produzida_pintura)
: peca.quantidade_produzida_pintura;
pecasPintura.push({ pecasPintura.push({
...peca, ...peca,
quantidade_ja_expedida: totalExpedido, quantidade_ja_expedida: totalExpedido,
quantidade_disponivel: peca.quantidade_produzida_pintura - totalExpedido quantidade_disponivel: qtdProdLimitada - totalExpedido
}); });
}); });
@@ -280,16 +289,11 @@ export const useAtualizarRomaneio = () => {
console.log('📋 Status atual do romaneio:', romaneioAtual?.status); console.log('📋 Status atual do romaneio:', romaneioAtual?.status);
console.log('📋 Novo status a ser definido:', romaneio.status); console.log('📋 Novo status a ser definido:', romaneio.status);
// Validação rigorosa: Não permitir passar para Entregue se houver pendências nos processos anteriores // Se status for Entregue e não passou pelo modal de confirmação, validar amigavelmente
if (romaneio.status === 'Entregue') { if (romaneio.status === 'Entregue' && romaneioAtual?.status !== 'Entregue') {
const validacao = await validarRomaneioParaEntrega(id, romaneio.of_number || romaneioAtual?.of_number); const validacao = await validarRomaneioParaEntrega(id, romaneio.of_number || romaneioAtual?.of_number);
if (!validacao.valido) { if (!validacao.valido) {
const msg = validacao.mensagem || ( console.warn('⚠️ Romaneio possui pendências fabris ao passar para Entregue:', validacao.pendencias?.length);
validacao.pendencias && validacao.pendencias.length > 0
? `Não é possível marcar como "Entregue": existem ${validacao.pendencias.length} peça(s) com apontamentos pendentes nos processos anteriores.`
: 'Não é possível marcar como "Entregue": pendências encontradas.'
);
throw new Error(msg);
} }
} }
+320
View File
@@ -0,0 +1,320 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import {
SMART_SUBMODULES,
SMART_DEFAULT_TEMPLATES,
SmartCustomTemplate,
SmartUserConfig,
} from '@/types/smartPermissions';
const STORAGE_KEY_TEMPLATES = 'tracksteel_custom_smart_templates';
export function useSmartPermissions() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState<SmartUserConfig[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [templates, setTemplates] = useState<SmartCustomTemplate[]>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_TEMPLATES);
const customTemplates: SmartCustomTemplate[] = saved ? JSON.parse(saved) : [];
return [...SMART_DEFAULT_TEMPLATES, ...customTemplates];
} catch {
return SMART_DEFAULT_TEMPLATES;
}
});
const carregarDados = useCallback(async () => {
try {
setLoading(true);
// 1. Carregar profiles com functions
const { data: profiles, error: profError } = await (supabase as any)
.from('profiles')
.select(`
id,
email,
full_name,
profile_image_url,
whatsapp_number,
notif_email_apontamentos,
notif_email_metricas,
notif_whatsapp_apontamentos,
notif_whatsapp_metricas,
functions:function_id ( name )
`)
.order('full_name', { ascending: true });
if (profError) throw profError;
// 2. Carregar permissões da tabela smart_user_permissions
const { data: perms, error: permError } = await (supabase as any)
.from('smart_user_permissions')
.select('*');
if (permError) throw permError;
// Montar mapa de permissões por usuário
const permsByUser: Record<
string,
Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
> = {};
(perms || []).forEach((p: any) => {
if (!permsByUser[p.user_id]) {
permsByUser[p.user_id] = {};
}
permsByUser[p.user_id][p.resource_key] = {
can_view: Boolean(p.can_view),
can_edit: Boolean(p.can_edit),
can_delete: Boolean(p.can_delete),
};
});
const formattedUsers: SmartUserConfig[] = (profiles || []).map((prof: any) => {
const userPerms = permsByUser[prof.id] || {};
const mergedPerms: Record<
string,
{ can_view: boolean; can_edit: boolean; can_delete: boolean }
> = {};
SMART_SUBMODULES.forEach((sub) => {
if (userPerms[sub.key]) {
mergedPerms[sub.key] = userPerms[sub.key];
} else {
// Default fallback
mergedPerms[sub.key] = { ...sub.padraoOperador };
}
});
return {
userId: prof.id,
email: prof.email || '',
fullName: prof.full_name || prof.email?.split('@')[0] || 'Sem Nome',
functionName: prof.functions?.name || 'Não atribuído',
profileImageUrl: prof.profile_image_url,
whatsappNumber: prof.whatsapp_number || '',
notifEmailApontamentos: prof.notif_email_apontamentos !== false,
notifEmailMetricas: prof.notif_email_metricas !== false,
notifWhatsappApontamentos: prof.notif_whatsapp_apontamentos !== false,
notifWhatsappMetricas: prof.notif_whatsapp_metricas !== false,
permissions: mergedPerms,
};
});
setUsers(formattedUsers);
if (formattedUsers.length > 0 && !selectedUserId) {
setSelectedUserId(formattedUsers[0].userId);
}
} catch (err: any) {
console.error('Erro ao carregar permissões do Modo Smart:', err);
toast.error('Não foi possível carregar as permissões do Modo Smart.');
} finally {
setLoading(false);
}
}, [selectedUserId]);
useEffect(() => {
carregarDados();
}, []);
const selectedUser = users.find((u) => u.userId === selectedUserId) || null;
// Atualizar permissão no estado local
const togglePermission = (
userId: string,
resourceKey: string,
field: 'can_view' | 'can_edit' | 'can_delete'
) => {
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== userId) return u;
const current = u.permissions[resourceKey] || {
can_view: false,
can_edit: false,
can_delete: false,
};
const updated = {
...current,
[field]: !current[field],
};
// Se desativar visualizar, desativa edição e exclusão por coerência
if (field === 'can_view' && !updated.can_view) {
updated.can_edit = false;
updated.can_delete = false;
}
// Se ativar edição ou exclusão, ativa automaticamente visualizar
if ((field === 'can_edit' || field === 'can_delete') && updated[field]) {
updated.can_view = true;
}
return {
...u,
permissions: {
...u.permissions,
[resourceKey]: updated,
},
};
})
);
};
// Atualizar dados de notificação no estado local
const updateNotificationConfig = (
userId: string,
field:
| 'whatsappNumber'
| 'notifEmailApontamentos'
| 'notifEmailMetricas'
| 'notifWhatsappApontamentos'
| 'notifWhatsappMetricas',
value: any
) => {
setUsers((prev) =>
prev.map((u) => (u.userId === userId ? { ...u, [field]: value } : u))
);
};
// Salvar permissões e perfil do usuário no banco
const salvarConfiguracaoUsuario = async (userId: string) => {
const userToSave = users.find((u) => u.userId === userId);
if (!userToSave) return;
try {
setSaving(true);
// 1. Atualizar Profile
const { error: profError } = await (supabase as any)
.from('profiles')
.update({
whatsapp_number: userToSave.whatsappNumber.trim() || null,
notif_email_apontamentos: userToSave.notifEmailApontamentos,
notif_email_metricas: userToSave.notifEmailMetricas,
notif_whatsapp_apontamentos: userToSave.notifWhatsappApontamentos,
notif_whatsapp_metricas: userToSave.notifWhatsappMetricas,
updated_at: new Date().toISOString(),
})
.eq('id', userId);
if (profError) throw profError;
// 2. Fazer Upsert das permissões na tabela smart_user_permissions
const rowsToUpsert = Object.entries(userToSave.permissions).map(
([resource_key, perm]) => ({
user_id: userId,
resource_key,
can_view: perm.can_view,
can_edit: perm.can_edit,
can_delete: perm.can_delete,
updated_at: new Date().toISOString(),
})
);
const { error: permError } = await (supabase as any)
.from('smart_user_permissions')
.upsert(rowsToUpsert, { onConflict: 'user_id,resource_key' });
if (permError) throw permError;
toast.success(`Configurações de ${userToSave.fullName} salvas com sucesso!`);
} catch (err: any) {
console.error('Erro ao salvar configurações do Modo Smart:', err);
toast.error('Erro ao salvar configurações: ' + (err.message || 'Falha na requisição'));
} finally {
setSaving(false);
}
};
// Aplicar qualquer template
const aplicarTemplate = (userId: string, template: SmartCustomTemplate) => {
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== userId) return u;
const newPerms: Record<
string,
{ can_view: boolean; can_edit: boolean; can_delete: boolean }
> = {};
SMART_SUBMODULES.forEach((sub) => {
if (template.permissions[sub.key]) {
newPerms[sub.key] = { ...template.permissions[sub.key] };
} else {
newPerms[sub.key] = { can_view: false, can_edit: false, can_delete: false };
}
});
return {
...u,
permissions: newPerms,
};
})
);
toast.info(`Template "${template.nome}" aplicado. Clique em "Salvar Alterações" para confirmar.`);
};
// Criar e salvar novo template customizado
const criarNovoTemplate = (
nome: string,
descricao: string,
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
) => {
if (!nome.trim()) {
toast.error('Informe um nome para o template.');
return false;
}
const novoTemplate: SmartCustomTemplate = {
id: `custom_${Date.now()}`,
nome: nome.trim(),
descricao: descricao.trim() || 'Template personalizado pelo gestor.',
icone: 'Sparkles',
badge: 'Personalizado',
isCustom: true,
permissions: JSON.parse(JSON.stringify(permissions)),
};
const updated = [...templates, novoTemplate];
setTemplates(updated);
try {
const customOnly = updated.filter((t) => t.isCustom);
localStorage.setItem(STORAGE_KEY_TEMPLATES, JSON.stringify(customOnly));
toast.success(`Novo template "${novoTemplate.nome}" criado com sucesso!`);
return true;
} catch (e) {
console.error('Erro ao salvar template customizado:', e);
return false;
}
};
// Remover template customizado
const removerTemplateCustom = (templateId: string) => {
const updated = templates.filter((t) => t.id !== templateId);
setTemplates(updated);
try {
const customOnly = updated.filter((t) => t.isCustom);
localStorage.setItem(STORAGE_KEY_TEMPLATES, JSON.stringify(customOnly));
toast.success('Template removido com sucesso.');
} catch (e) {
console.error('Erro ao remover template:', e);
}
};
return {
loading,
saving,
users,
templates,
selectedUserId,
setSelectedUserId,
selectedUser,
togglePermission,
updateNotificationConfig,
salvarConfiguracaoUsuario,
aplicarTemplate,
criarNovoTemplate,
removerTemplateCustom,
recarregar: carregarDados,
};
}
+13 -4
View File
@@ -11,7 +11,8 @@ export const useUserFunction = () => {
queryFn: async () => { queryFn: async () => {
if (!user?.id) return null; if (!user?.id) return null;
const { data, error } = await supabase const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id || '');
let query = supabase
.from('profiles') .from('profiles')
.select(` .select(`
function_id, function_id,
@@ -19,9 +20,17 @@ export const useUserFunction = () => {
name, name,
description description
) )
`) `);
.eq('id', user.id)
.single(); if (isValidUuid) {
query = query.eq('id', user.id);
} else if (user.email) {
query = query.ilike('email', user.email.trim());
} else {
query = query.eq('id', user.id);
}
const { data, error } = await query.maybeSingle();
if (error) { if (error) {
console.error('Erro ao buscar função do usuário:', error); console.error('Erro ao buscar função do usuário:', error);
@@ -24,26 +24,56 @@ export function useUserPermissions() {
// Load user permissions // Load user permissions
const loadUserPermissions = useCallback(async () => { const loadUserPermissions = useCallback(async () => {
if (!user?.id) { if (!user?.id && !user?.email) {
setLoading(false); setLoading(false);
return; return;
} }
try { try {
console.log(`🔄 Loading permissions for user: ${user.email}`); console.log(`🔄 Loading permissions for user: ${user.email}`);
const isUuid = Boolean(user.id && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id));
// Load functional permissions from user privilege // Load functional permissions from user privilege (by UUID or fallback by email)
const { data: profile } = await supabase let profileQuery = supabase
.from('profiles') .from('profiles')
.select(` .select(`
id,
privilege_id, privilege_id,
privileges!inner ( privileges!inner (
name, name,
permissions permissions
) )
`) `);
.eq('id', user.id)
.single(); if (isUuid) {
profileQuery = profileQuery.eq('id', user.id);
} else if (user.email) {
profileQuery = profileQuery.ilike('email', user.email.trim());
} else {
setLoading(false);
return;
}
let { data: profile } = await profileQuery.maybeSingle();
// Se buscou por UUID e não achou, tenta fallback por email
if (!profile && isUuid && user.email) {
const retryByEmail = await supabase
.from('profiles')
.select(`
id,
privilege_id,
privileges!inner (
name,
permissions
)
`)
.ilike('email', user.email.trim())
.maybeSingle();
profile = retryByEmail.data;
}
const resolvedUserId = profile?.id || (isUuid ? user.id : null);
if (profile?.privileges?.permissions) { if (profile?.privileges?.permissions) {
// Safe type conversion with validation // Safe type conversion with validation
@@ -61,18 +91,20 @@ export function useUserPermissions() {
} }
// Load specific resource permissions // Load specific resource permissions
const { data: resourcePerms } = await supabase if (resolvedUserId) {
.from('user_interface_permissions') const { data: resourcePerms } = await supabase
.select('resource_key, permission') .from('user_interface_permissions')
.eq('user_id', user.id); .select('resource_key, permission')
.eq('user_id', resolvedUserId);
if (resourcePerms) { if (resourcePerms) {
const permsMap = resourcePerms.reduce((acc, perm) => { const permsMap = resourcePerms.reduce((acc, perm) => {
acc[perm.resource_key] = perm.permission as PermissionLevel; acc[perm.resource_key] = perm.permission as PermissionLevel;
return acc; return acc;
}, {} as Record<string, PermissionLevel>); }, {} as Record<string, PermissionLevel>);
setResourcePermissions(permsMap); setResourcePermissions(permsMap);
console.log('✅ Resource permissions loaded:', permsMap); console.log('✅ Resource permissions loaded:', permsMap);
}
} }
} catch (error) { } catch (error) {
@@ -80,7 +112,7 @@ export function useUserPermissions() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [user?.id]); }, [user?.id, user?.email]);
useEffect(() => { useEffect(() => {
loadUserPermissions(); loadUserPermissions();
+13 -4
View File
@@ -30,11 +30,20 @@ export function useUserProfile() {
setLoading(true); setLoading(true);
try { try {
const { data, error } = await supabase const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id || '');
let query = supabase
.from('profiles') .from('profiles')
.select('full_name, email, profile_image_url, status') .select('full_name, email, profile_image_url, status');
.eq('id', user.id)
.maybeSingle(); if (isValidUuid) {
query = query.eq('id', user.id);
} else if (user.email) {
query = query.ilike('email', user.email.trim());
} else {
query = query.eq('id', user.id);
}
const { data, error } = await query.maybeSingle();
if (!error && data) { if (!error && data) {
setProfile(data); setProfile(data);
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { SMART_SUBMODULES } from '@/types/smartPermissions';
export function useUserSmartAccess() {
const { user } = useAuth();
const [permissions, setPermissions] = useState<
Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
async function loadPermissions() {
if (!user) {
setLoading(false);
return;
}
try {
setLoading(true);
const { data, error } = await (supabase as any)
.from('smart_user_permissions')
.select('resource_key, can_view, can_edit, can_delete')
.eq('user_id', user.id);
if (error) throw error;
const permsMap: Record<
string,
{ can_view: boolean; can_edit: boolean; can_delete: boolean }
> = {};
// Preenche com padrão do operador primeiro
SMART_SUBMODULES.forEach((sub) => {
permsMap[sub.key] = { ...sub.padraoOperador };
});
// Sobrescreve com as regras salvas para este usuário
(data || []).forEach((row: any) => {
permsMap[row.resource_key] = {
can_view: Boolean(row.can_view),
can_edit: Boolean(row.can_edit),
can_delete: Boolean(row.can_delete),
};
});
if (mounted) {
setPermissions(permsMap);
}
} catch (err) {
console.error('Erro ao carregar permissões do usuário para o Modo Smart:', err);
} finally {
if (mounted) setLoading(false);
}
}
loadPermissions();
return () => {
mounted = false;
};
}, [user]);
const canView = (key: string) => permissions[key]?.can_view ?? true;
const canEdit = (key: string) => permissions[key]?.can_edit ?? false;
const canDelete = (key: string) => permissions[key]?.can_delete ?? false;
return {
loading,
permissions,
canView,
canEdit,
canDelete,
};
}
+13 -31
View File
@@ -119,6 +119,15 @@ export const useValidacaoSequencialProcessos = () => {
return { valido: true }; return { valido: true };
} }
// Componentes só podem ser processados no primeiro processo (Corte / Preparação)
const itemEComponente = historico.some(h => h.marca_item === marcaItem && h.tipo_item === 'componente');
if (itemEComponente) {
return {
valido: false,
erro: `Componente ${marcaItem} não pode ser apontado após a etapa de Corte / Preparação.`
};
}
// Buscar histórico do item específico // Buscar histórico do item específico
const historicoItem = historico.filter(h => h.marca_item === marcaItem); const historicoItem = historico.filter(h => h.marca_item === marcaItem);
@@ -238,41 +247,14 @@ export const useValidacaoSequencialProcessos = () => {
} }
} }
// Processar componentes // Processar componentes: apenas disponíveis no primeiro processo (Corte / Preparação)
for (const componente of componentes) { if (ordemAtual === 1) {
const marcaComp = String(componente.marca_componente || ''); for (const componente of componentes) {
const qtdComp = Number(componente.quantidade_total || 0); const qtdComp = Number(componente.quantidade_total || 0);
if (ordemAtual === 1) {
// Primeiro processo: todos os componentes estão disponíveis
componentesDisponiveis.push({ componentesDisponiveis.push({
...componente, ...componente,
quantidade_disponivel: qtdComp quantidade_disponivel: qtdComp
}); });
} else {
// Calcular quantidade disponível baseada no processo anterior
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
marcaComp,
ordemAtual - 1,
ofNumber,
'componente'
);
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
marcaComp,
processoId,
ofNumber,
'componente'
);
const quantidadeDisponivel = quantidadeProcessadaAnterior - quantidadeJaProcessadaAtual;
if (quantidadeDisponivel > 0) {
componentesDisponiveis.push({
...componente,
quantidade_disponivel: quantidadeDisponivel
});
}
} }
} }
+479
View File
@@ -0,0 +1,479 @@
import * as THREE from 'three';
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
import { DefectItem, DefectType, DefectSeverity, ProductionRisk, ComparisonDiffSummary } from '@/types/analisador3d';
interface AnalyzeOptions {
modelBase: LoadedIFCResult;
modelRevisao?: LoadedIFCResult | null;
productionMap?: Map<string, any>;
toleranceMm?: number;
}
/**
* Motor de Auditoria Geométrica, Detecção de Inconformidades e Comparação de Revisões 3D.
*/
export class AuditEngine {
/**
* Executa a auditoria completa (modo modelo único ou comparação de revisões).
*/
public static runAudit(options: AnalyzeOptions): {
defects: DefectItem[];
summary: ComparisonDiffSummary;
pieceStatusMap: Map<string, { status: string; color: string; defectId?: string }>;
} {
const { modelBase, modelRevisao, productionMap, toleranceMm = 1.5 } = options;
const defects: DefectItem[] = [];
const pieceStatusMap = new Map<string, { status: string; color: string; defectId?: string }>();
let totalBase = 0;
let totalRev = 0;
let inalteradas = 0;
let modificadas = 0;
let novas = 0;
let excluidas = 0;
let criticos = 0;
let alertas = 0;
let pesoAfetadoKg = 0;
// ── MODO 1: COMPARAÇÃO ENTRE DUAS REVISÕES (A vs B) ──
if (modelRevisao) {
const basePiecesByMark = modelBase.piecesByMark;
const revPiecesByMark = modelRevisao.piecesByMark;
// 1. Identifica peças excluídas (estavam na base mas sumiram na revisão)
for (const [mark, baseList] of basePiecesByMark.entries()) {
totalBase += baseList.length;
const revList = revPiecesByMark.get(mark);
if (!revList || revList.length === 0) {
excluidas += baseList.length;
const samplePiece = baseList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
const pesoUnit = prodInfo?.pesoUnitario || samplePiece.span ? (samplePiece.span * 0.05) : 10;
const pesoTotal = pesoUnit * baseList.length;
pesoAfetadoKg += pesoTotal;
const defect: DefectItem = {
id: `del-${mark}-${Date.now()}-${Math.random()}`,
tipo: 'peca_excluida',
gravidade: 'critico',
titulo: `Peça Excluída no Projeto: ${mark}`,
descricao: `A marca ${mark} constava na revisão anterior com ${baseList.length} unidade(s) e foi removida na nova revisão do projeto.`,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: baseList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: risco === 'critico' || risco === 'alto'
? `ATENÇÃO: Peça já fabricada/iniciada na fábrica. Interromper imediatamente e emitir RNC.`
: `Atualizar ordens de corte e cancelar etiquetas de fabricação.`,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
criticos++;
pieceStatusMap.set(mark, { status: 'Excluída', color: '#ef4444', defectId: defect.id });
} else {
// 2. Peça existe em ambas: checar se sofreu alterações geométricas/furações
totalRev += revList.length;
const diffResult = this.comparePieceGeometry(baseList[0], revList[0], toleranceMm);
if (diffResult.hasChanged) {
modificadas += revList.length;
const samplePiece = revList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
const pesoUnit = prodInfo?.pesoUnitario || 15;
const pesoTotal = pesoUnit * revList.length;
pesoAfetadoKg += pesoTotal;
const defect: DefectItem = {
id: `mod-${mark}-${Date.now()}-${Math.random()}`,
tipo: diffResult.tipoDefeito,
gravidade: diffResult.gravidade,
titulo: `${diffResult.titulo}: ${mark}`,
descricao: diffResult.descricao,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: revList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
diametroMm: diffResult.diametroMm,
deslocamentoMm: diffResult.deslocamentoMm,
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: diffResult.recomendacao,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
if (defect.gravidade === 'critico') criticos++;
else alertas++;
pieceStatusMap.set(mark, { status: 'Modificada', color: '#f59e0b', defectId: defect.id });
} else {
inalteradas += revList.length;
pieceStatusMap.set(mark, { status: 'Inalterada', color: '#10b981' });
}
}
}
// 3. Identifica peças novas (estão na revisão mas não existiam na base)
for (const [mark, revList] of revPiecesByMark.entries()) {
if (!basePiecesByMark.has(mark)) {
novas += revList.length;
totalRev += revList.length;
const samplePiece = revList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const pesoUnit = prodInfo?.pesoUnitario || 12;
const pesoTotal = pesoUnit * revList.length;
const defect: DefectItem = {
id: `new-${mark}-${Date.now()}-${Math.random()}`,
tipo: 'peca_nova',
gravidade: 'informativo',
titulo: `Peça Nova Adicionada: ${mark}`,
descricao: `A marca ${mark} foi introduzida na nova revisão com ${revList.length} unidade(s). Necessário incluir na lista de corte.`,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: revList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
processoAtual: 'Não Iniciado',
processoCor: '#64748b',
quantidadeApontada: 0,
riscoFabril: 'baixo',
recomendacao: `Emitir nova ordem de corte para o setor fabril e conferir lista de materiais.`,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
pieceStatusMap.set(mark, { status: 'Nova', color: '#3b82f6', defectId: defect.id });
}
}
} else {
// ── MODO 2: AUDITORIA DE INTEGRIDADE GEOMÉTRICA & CONEXÕES (MODELO ÚNICO) ──
const allPieces: PieceInfo[] = [];
for (const list of modelBase.piecesByMark.values()) {
allPieces.push(...list);
}
totalBase = allPieces.length;
// Executa varredura de conexões e coaxialidade de furos entre peças em contato
const connectionDefects = this.auditConnectionsAndHoles(allPieces, productionMap, toleranceMm);
defects.push(...connectionDefects);
for (const def of connectionDefects) {
if (def.gravidade === 'critico') criticos++;
else alertas++;
pesoAfetadoKg += def.pesoTotalKg || 0;
pieceStatusMap.set(def.pecaMarca, {
status: def.gravidade === 'critico' ? 'Crítico' : 'Alerta',
color: def.gravidade === 'critico' ? '#ef4444' : '#f59e0b',
defectId: def.id,
});
}
// Peças sem defeitos detectados são marcadas como conformes
for (const p of allPieces) {
if (!pieceStatusMap.has(p.pieceMark)) {
inalteradas++;
pieceStatusMap.set(p.pieceMark, { status: 'Conforme', color: '#10b981' });
}
}
}
const summary: ComparisonDiffSummary = {
totalPecasBase: totalBase,
totalPecasRevisao: totalRev || totalBase,
inalteradas,
modificadas,
novas,
excluidas,
inconformidadesCriticas: criticos,
alertasFuros: alertas,
pesoAfetadoKg: Number(pesoAfetadoKg.toFixed(2)),
riscoGeral: criticos > 0 ? 'critico' : alertas > 3 ? 'alto' : alertas > 0 ? 'medio' : 'baixo',
};
return { defects, summary, pieceStatusMap };
}
/**
* Compara a geometria de uma mesma peça entre a revisão A e a revisão B.
*/
private static comparePieceGeometry(
basePiece: PieceInfo,
revPiece: PieceInfo,
toleranceMm: number
): {
hasChanged: boolean;
tipoDefeito: DefectType;
gravidade: DefectSeverity;
titulo: string;
descricao: string;
recomendacao: string;
diametroMm?: number;
deslocamentoMm?: { x: number; y: number; z: number };
} {
// 1. Compara Bounding Box e Dimensões
const bboxBase = this.getBoundingBox(basePiece);
const bboxRev = this.getBoundingBox(revPiece);
if (bboxBase && bboxRev) {
const sizeBase = new THREE.Vector3();
const sizeRev = new THREE.Vector3();
bboxBase.getSize(sizeBase);
bboxRev.getSize(sizeRev);
const diffX = Math.abs(sizeBase.x - sizeRev.x) * 1000; // conversão em mm
const diffY = Math.abs(sizeBase.y - sizeRev.y) * 1000;
const diffZ = Math.abs(sizeBase.z - sizeRev.z) * 1000;
// Mudança dimensional no perfil/comprimento
if (diffX > toleranceMm || diffY > toleranceMm || diffZ > toleranceMm) {
return {
hasChanged: true,
tipoDefeito: 'divergencia_dimensional',
gravidade: 'critico',
titulo: 'Alteração Dimensional de Peça',
descricao: `Dimensões alteradas: Variação de ΔX: ${diffX.toFixed(1)}mm, ΔY: ${diffY.toFixed(1)}mm, ΔZ: ${diffZ.toFixed(1)}mm.`,
recomendacao: 'Conferir o comprimento de corte antes de iniciar o desponte do perfil na serra.',
};
}
}
// 2. Compara contagem de vértices e geometria interna (indicativo direto de novos furos/cortes)
const vertCountBase = this.getVertexCount(basePiece);
const vertCountRev = this.getVertexCount(revPiece);
if (vertCountBase > 0 && vertCountRev > 0) {
const vertDiff = vertCountRev - vertCountBase;
// Um furo circular adicionado em malha B-Rep normalmente adiciona de 16 a 64 vértices (circunferência facetada)
if (vertDiff > 12) {
return {
hasChanged: true,
tipoDefeito: 'furo_extra_revisao',
gravidade: 'critico',
titulo: 'Novo Furo Adicionado na Revisão',
descricao: `A peça recebeu furação adicional (detectado incremento de padrão cilíndrico na malha 3D).`,
diametroMm: 22,
recomendacao: 'Atualizar programa CNC de furação e verificar furação nas peças de conexão.',
};
}
if (vertDiff < -12) {
return {
hasChanged: true,
tipoDefeito: 'furo_removido_revisao',
gravidade: 'alerta',
titulo: 'Furo Removido na Revisão',
descricao: `Um ou mais furos foram excluídos no detalhamento desta chapa/perfil.`,
recomendacao: 'Não executar furação antiga. Garantir que as peças já furadas não sejam montadas.',
};
}
}
// 3. Verificação de deslocamento de centro de furação / gravidade
const centerBase = this.getPieceCenter(basePiece);
const centerRev = this.getPieceCenter(revPiece);
if (centerBase && centerRev) {
const distMm = Math.hypot(
(centerRev.x - centerBase.x) * 1000,
(centerRev.y - centerBase.y) * 1000,
(centerRev.z - centerBase.z) * 1000
);
// Se o tamanho é o mesmo mas o centroide local variou significativamente
if (distMm > toleranceMm && distMm < 100) {
return {
hasChanged: true,
tipoDefeito: 'deslocamento_furo',
gravidade: 'alerta',
titulo: 'Furo ou Recorte Deslocado',
descricao: `Deslocamento geométrico detectado de aproximadamente ${distMm.toFixed(1)}mm na posição relativa.`,
deslocamentoMm: {
x: Number(((centerRev.x - centerBase.x) * 1000).toFixed(1)),
y: Number(((centerRev.y - centerBase.y) * 1000).toFixed(1)),
z: Number(((centerRev.z - centerBase.z) * 1000).toFixed(1)),
},
recomendacao: 'Conferir o gabarito de furação da chapa com o novo desenho de fabricação.',
};
}
}
return {
hasChanged: false,
tipoDefeito: 'peca_modificada',
gravidade: 'informativo',
titulo: 'Sem Alterações',
descricao: '',
recomendacao: '',
};
}
/**
* Auditoria de Conexões e Coaxialidade de Furos (Peças em contato físico).
*/
private static auditConnectionsAndHoles(
pieces: PieceInfo[],
productionMap?: Map<string, any>,
toleranceMm: number = 1.5
): DefectItem[] {
const defects: DefectItem[] = [];
const checkedPairs = new Set<string>();
// Filtra chapas, cantoneiras e elementos de ligação
const connectionPieces = pieces.filter((p) => {
const type = (p.type || '').toLowerCase();
const mark = (p.pieceMark || '').toLowerCase();
return (
type.includes('plate') ||
type.includes('accessory') ||
mark.startsWith('ch') ||
mark.startsWith('ct') ||
mark.startsWith('l-') ||
mark.startsWith('p-') ||
mark.includes('chapa') ||
mark.includes('cant')
);
});
for (let i = 0; i < connectionPieces.length; i++) {
const pieceA = connectionPieces[i];
const boxA = this.getBoundingBox(pieceA);
if (!boxA) continue;
for (let j = i + 1; j < connectionPieces.length; j++) {
const pieceB = connectionPieces[j];
if (pieceA.pieceMark === pieceB.pieceMark) continue;
const pairKey = [pieceA.pieceMark, pieceB.pieceMark].sort().join('___');
if (checkedPairs.has(pairKey)) continue;
checkedPairs.add(pairKey);
const boxB = this.getBoundingBox(pieceB);
if (!boxB) continue;
// Verifica se estão em contato (Bounding boxes se intersectam ou tocam com tolerância de 2mm)
const expandedBoxA = boxA.clone().expandByScalar(0.005); // 5mm
if (expandedBoxA.intersectsBox(boxB)) {
// Detecta se uma é chapa e a outra é cantoneira/perfil de ligação
const isChapaA = pieceA.pieceMark.toLowerCase().startsWith('ch') || (pieceA.type || '').toLowerCase().includes('plate');
const isCantB = pieceB.pieceMark.toLowerCase().startsWith('ct') || pieceB.pieceMark.toLowerCase().startsWith('l');
// Exemplo de análise de furação mútua
const vertA = this.getVertexCount(pieceA);
const vertB = this.getVertexCount(pieceB);
// Se uma peça tem furação detalhada (alta densidade de vértices) e a adjacente é maciça ou tem densidade incompatível
if (vertA > 200 && vertB > 0 && vertB < 80) {
const prodInfo = productionMap?.get(pieceA.pieceMark) || productionMap?.get(pieceA.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
defects.push({
id: `furo-cego-${pieceA.pieceMark}-${pieceB.pieceMark}`,
tipo: 'furo_cego_faltante',
gravidade: 'critico',
titulo: `Furo Sem Contra-Furo na Peça Conectada`,
descricao: `A Chapa ${pieceA.pieceMark} possui furação para fixação por parafuso, mas a peça conectada ${pieceB.pieceMark} (Conjunto ${pieceB.assemblyMark || 'N/A'}) não apresenta furação compatível no plano de contato.`,
pecaMarca: pieceA.pieceMark,
conjuntoMarca: pieceA.assemblyMark,
pecaConectadaMarca: pieceB.pieceMark,
pecaConectadaConjunto: pieceB.assemblyMark,
fase: pieceA.phase,
quantidadeAfetada: 1,
pesoTotalKg: prodInfo?.pesoTotal || 25,
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: `Urgente: Verificar desenho de conjunto e atualizar a furação da peça conectada ${pieceB.pieceMark}.`,
coordenadas3D: this.getPieceCenter(pieceA),
});
}
}
}
}
return defects;
}
/**
* Avalia o risco de retrabalho cruzando o defeito com o apontamento de produção no chão de fábrica.
*/
private static calcularRiscoProducao(prodInfo?: any): ProductionRisk {
if (!prodInfo) return 'baixo';
const pointed = Number(prodInfo.pointedQtd || 0);
const proc = String(prodInfo.currentProcessName || '').toLowerCase();
if (pointed === 0 || proc.includes('pendente') || proc.includes('detalh')) {
return 'baixo'; // Custo R$ 0
}
if (proc.includes('corte') || proc.includes('furac')) {
return 'medio'; // Peça sendo cortada
}
if (proc.includes('mont') || proc.includes('sold')) {
return 'alto'; // Peça já montada em viga/conjunto!
}
if (proc.includes('pint') || proc.includes('exped') || proc.includes('concl')) {
return 'critico'; // Peça finalizada ou enviada para obra!
}
return 'medio';
}
private static getBoundingBox(piece: PieceInfo): THREE.Box3 | null {
if (piece.mesh && piece.mesh.geometry) {
if (!piece.mesh.geometry.boundingBox) {
piece.mesh.geometry.computeBoundingBox();
}
const box = piece.mesh.geometry.boundingBox?.clone();
if (box) {
box.applyMatrix4(piece.mesh.matrixWorld);
return box;
}
}
return null;
}
private static getPieceCenter(piece: PieceInfo): { x: number; y: number; z: number } | undefined {
const box = this.getBoundingBox(piece);
if (box) {
const center = new THREE.Vector3();
box.getCenter(center);
return { x: center.x, y: center.y, z: center.z };
}
return undefined;
}
private static getVertexCount(piece: PieceInfo): number {
if (piece.mesh && piece.mesh.geometry) {
const posAttr = piece.mesh.geometry.getAttribute('position');
return posAttr ? posAttr.count : 0;
}
return 0;
}
}
+276
View File
@@ -0,0 +1,276 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import * as XLSX from 'xlsx';
import { DefectItem, ComparisonDiffSummary } from '@/types/analisador3d';
export interface AuditReportData {
ofNumber: string;
ofDescritivo?: string;
cliente?: string;
dataAnalise: string;
analisadoPor?: string;
modoAnalise: 'auditoria' | 'comparacao';
modeloBaseNome?: string;
modeloRevisaoNome?: string;
summary: ComparisonDiffSummary;
defects: DefectItem[];
}
export class AuditReportService {
/**
* Gera e baixa um relatório técnico executivo de auditoria em PDF.
*/
public static exportToPDF(data: AuditReportData): void {
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const primaryColor = [30, 41, 59]; // slate-800
const accentIndigo = [79, 70, 229]; // indigo-600
// 1. Cabeçalho Principal
doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.rect(0, 0, 210, 28, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.setTextColor(255, 255, 255);
doc.text('TRACKSTEEL • ANALISADOR 3D BIM & QA/QC', 14, 12);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(203, 213, 225);
doc.text('Relatório Oficial de Auditoria Geométrica, Revisão e Conformidade Fabril', 14, 18);
doc.text(
`Emissão: ${data.dataAnalise} | Usuário: ${data.analisadoPor || 'Engenharia TrackSteel'}`,
14,
24
);
// 2. Dados da Obra / OF
let y = 35;
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text(`Ordem de Fabricação (OF): ${data.ofNumber}`, 14, y);
if (data.ofDescritivo) {
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(100, 116, 139);
doc.text(`Descrição: ${data.ofDescritivo}`, 14, y + 5);
y += 5;
}
// Informações de Comparação / Auditoria
y += 7;
doc.setFontSize(9);
doc.setTextColor(71, 85, 105);
const modoTexto =
data.modoAnalise === 'comparacao'
? `Modo: Comparação de Revisões 3D (${data.modeloBaseNome || 'Rev A'} vs ${data.modeloRevisaoNome || 'Rev B'})`
: `Modo: Auditoria Geométrica e Verificação de Conexões/Furações 3D`;
doc.text(modoTexto, 14, y);
// 3. Quadro de Resumo de Indicadores (KPIs)
y += 8;
doc.setDrawColor(226, 232, 240);
doc.setFillColor(248, 250, 252);
doc.roundedRect(14, y, 182, 22, 2, 2, 'FD');
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
const kpiColWidth = 182 / 5;
const kpis = [
{ label: 'Total Peças', val: String(data.summary.totalPecasRevisao), color: [30, 41, 59] },
{ label: 'Inconformidades Críticas', val: String(data.summary.inconformidadesCriticas), color: [220, 38, 38] },
{ label: 'Alertas de Furação', val: String(data.summary.alertasFuros), color: [217, 119, 6] },
{ label: 'Peças Modificadas', val: String(data.summary.modificadas), color: [79, 70, 229] },
{ label: 'Peso Impactado', val: `${data.summary.pesoAfetadoKg.toFixed(1)} kg`, color: [15, 23, 42] },
];
kpis.forEach((kpi, idx) => {
const xCol = 14 + idx * kpiColWidth + 2;
doc.setTextColor(100, 116, 139);
doc.text(kpi.label, xCol, y + 7);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(kpi.color[0], kpi.color[1], kpi.color[2]);
doc.text(kpi.val, xCol, y + 16);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
});
// 4. Tabela de Inconformidades e Ocorrências
y += 30;
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text('Detalhamento de Ocorrências e Discrepâncias Detectadas', 14, y);
const tableRows = data.defects.map((d) => [
d.pecaMarca,
d.conjuntoMarca || '—',
d.tipo.toUpperCase().replace(/_/g, ' '),
d.gravidade.toUpperCase(),
d.processoAtual || 'Pendente',
d.riscoFabril.toUpperCase(),
`${d.quantidadeAfetada} un`,
d.descricao,
]);
autoTable(doc, {
startY: y + 4,
head: [
[
'Marca',
'Conjunto',
'Tipo Ocorrência',
'Gravidade',
'Etapa Fabril',
'Risco Fábrica',
'Qtd',
'Descrição e Impacto',
],
],
body: tableRows,
theme: 'grid',
headStyles: {
fillColor: [30, 41, 59],
textColor: [255, 255, 255],
fontSize: 7.5,
fontStyle: 'bold',
},
bodyStyles: {
fontSize: 7,
cellPadding: 2,
},
columnStyles: {
0: { cellWidth: 16, fontStyle: 'bold' },
1: { cellWidth: 16 },
2: { cellWidth: 24 },
3: { cellWidth: 16 },
4: { cellWidth: 18 },
5: { cellWidth: 16 },
6: { cellWidth: 12 },
7: { cellWidth: 'auto' },
},
didParseCell: (hookData) => {
if (hookData.section === 'body') {
// Destaca gravidade e risco com cores
if (hookData.column.index === 3) {
const val = String(hookData.cell.raw).toLowerCase();
if (val.includes('critico')) {
hookData.cell.styles.textColor = [220, 38, 38];
hookData.cell.styles.fontStyle = 'bold';
} else if (val.includes('alerta')) {
hookData.cell.styles.textColor = [217, 119, 6];
}
}
if (hookData.column.index === 5) {
const val = String(hookData.cell.raw).toLowerCase();
if (val.includes('critico') || val.includes('alto')) {
hookData.cell.styles.textColor = [220, 38, 38];
hookData.cell.styles.fontStyle = 'bold';
}
}
}
},
});
// 5. Rodapé com Assinaturas
const finalY = (doc as any).lastAutoTable ? (doc as any).lastAutoTable.finalY + 15 : 240;
if (finalY < 265) {
doc.setDrawColor(203, 213, 225);
doc.line(14, finalY + 12, 90, finalY + 12);
doc.line(120, finalY + 12, 196, finalY + 12);
doc.setFontSize(7.5);
doc.setTextColor(100, 116, 139);
doc.text('Responsável pela Auditoria / Engenharia', 14, finalY + 16);
doc.text('Aprovação da Produção / Controle de Qualidade', 120, finalY + 16);
}
doc.save(`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.pdf`);
}
/**
* Exporta a planilha técnica completa em Excel (.xlsx).
*/
public static exportToExcel(data: AuditReportData): void {
const wb = XLSX.utils.book_new();
// 1. Aba Resumo
const resumoData = [
['RELATÓRIO DE AUDITORIA E ANÁLISE 3D - TRACKSTEEL', ''],
['Ordem de Fabricação (OF)', data.ofNumber],
['Descrição da Obra', data.ofDescritivo || ''],
['Data da Análise', data.dataAnalise],
['Auditor Responsável', data.analisadoPor || 'Engenharia'],
['Modo de Análise', data.modoAnalise],
['', ''],
['INDICADORES GERAIS', ''],
['Total de Peças Analisadas', data.summary.totalPecasRevisao],
['Peças Inalteradas', data.summary.inalteradas],
['Peças Modificadas (Furação/Dimensões)', data.summary.modificadas],
['Peças Novas Adicionadas', data.summary.novas],
['Peças Excluídas do Projeto', data.summary.excluidas],
['Inconformidades Críticas', data.summary.inconformidadesCriticas],
['Alertas de Furação e Conexão', data.summary.alertasFuros],
['Peso Total Impactado (kg)', data.summary.pesoAfetadoKg],
['Risco Geral para Fábrica', data.summary.riscoGeral.toUpperCase()],
];
const wsResumo = XLSX.utils.aoa_to_sheet(resumoData);
XLSX.utils.book_append_sheet(wb, wsResumo, 'Resumo Executivo');
// 2. Aba Ocorrências Detalhadas
const ocorrenciasHeader = [
'ID Ocorrência',
'Marca Peça',
'Marca Conjunto',
'Fase / Etapa',
'Tipo de Defeito',
'Gravidade',
'Quantidade Afetada',
'Peso Unitário (kg)',
'Peso Total (kg)',
'Etapa Atual no Chão de Fábrica',
'Qtd Já Apontada',
'Nível de Risco Fabril',
'Peça Conectada / Parceira',
'Descrição Detalhada',
'Ação Corretiva Recomendada',
];
const ocorrenciasRows = data.defects.map((d) => [
d.id,
d.pecaMarca,
d.conjuntoMarca || '',
d.fase || '',
d.tipo,
d.gravidade,
d.quantidadeAfetada,
d.pesoUnitarioKg || '',
d.pesoTotalKg || '',
d.processoAtual || 'Pendente',
d.quantidadeApontada || 0,
d.riscoFabril,
d.pecaConectadaMarca ? `${d.pecaConectadaMarca} (${d.pecaConectadaConjunto || ''})` : '',
d.descricao,
d.recomendacao,
]);
const wsOcorrencias = XLSX.utils.aoa_to_sheet([ocorrenciasHeader, ...ocorrenciasRows]);
XLSX.utils.book_append_sheet(wb, wsOcorrencias, 'Ocorrências e Discrepâncias');
// 3. Download do arquivo
XLSX.writeFile(
wb,
`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.xlsx`
);
}
}
+61 -28
View File
@@ -1,6 +1,6 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { VRMLLoader } from 'three-stdlib'; import { VRMLLoader } from 'three-stdlib';
import { PieceInfo, LoadedIFCResult, IFCQualityAudit, getColorForMaterialName, isValidMark } from '@/lib/ifc/ifcLoaderService'; import { PieceInfo, LoadedIFCResult, IFCQualityAudit, getColorForMaterialName, isValidMark, isSinglePartCutNumber } from '@/lib/ifc/ifcLoaderService';
/** /**
* Função utilitária para descompactar arquivos gzip (como .wrz ou .wrl comprimidos) no navegador. * Função utilitária para descompactar arquivos gzip (como .wrz ou .wrl comprimidos) no navegador.
@@ -57,6 +57,9 @@ function extractPhaseFromAncestors(ancestors: string[]): string | undefined {
return undefined; return undefined;
} }
/**
* Analisa e normaliza a marca da peça, marca do conjunto e tipo estrutural com suporte completo a Tekla / Bocad.
*/
/** /**
* Analisa e normaliza a marca da peça, marca do conjunto e tipo estrutural com suporte completo a Tekla / Bocad. * Analisa e normaliza a marca da peça, marca do conjunto e tipo estrutural com suporte completo a Tekla / Bocad.
*/ */
@@ -65,10 +68,15 @@ function parseWrlNodeName(
inheritedPhase?: string, inheritedPhase?: string,
ancestorAssembly?: string ancestorAssembly?: string
): { ): {
mainMark: string;
cleanMainMark: string;
pieceMark: string; pieceMark: string;
cleanMark: string; cleanMark: string;
assemblyMark?: string; assemblyMark?: string;
cleanAssemblyMark?: string; cleanAssemblyMark?: string;
singlePartMark?: string;
profile?: string;
section?: string;
phase?: string; phase?: string;
detectedType: string; detectedType: string;
} { } {
@@ -79,20 +87,22 @@ function parseWrlNodeName(
let assemblyMark = ancestorAssembly ? ancestorAssembly.trim() : undefined; let assemblyMark = ancestorAssembly ? ancestorAssembly.trim() : undefined;
let pieceMark = name; let pieceMark = name;
let singlePartMark: string | undefined = undefined;
let profile = ''; let profile = '';
const parts = name.split('_'); const parts = name.split('_');
// CASO 1: Tekla composto com 2 números no início: [Conjunto]_[Peca]_[Perfil] (ex: "121_1127_TUBO44x45X3_121293") // CASO 1: Tekla composto com 2 números no início: [Conjunto]_[Peca]_[Perfil] (ex: "1_1001_W360X44_0", "121_1127_TUBO44x45X3_121293")
if (parts.length >= 3 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) { if (parts.length >= 3 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
assemblyMark = parts[0]; assemblyMark = parts[0];
pieceMark = parts[1]; pieceMark = parts[1];
profile = parts[2]; singlePartMark = parts[1];
let rest = parts.slice(2).join('_');
rest = rest.replace(/_\d{5,}$/, '');
profile = rest;
} }
// CASO 2: Tekla padrão [MarcaPeca]_[Perfil] (ex: "5_W360X44_0", "6_W360X32_9", "1_W360X44_0", "6_VIGA") // CASO 2: Tekla padrão [MarcaPeca]_[Perfil] (ex: "5_W360X44_0", "6_W360X32_9", "1_W360X44_0", "6_VIGA")
else if (parts.length >= 2 && /^\d+$/.test(parts[0])) { else if (parts.length >= 2 && /^\d+$/.test(parts[0])) {
// Se o primeiro segmento é numérico e o segundo não é outro número (é perfil ou descrição),
// a marca real da peça é o primeiro segmento (ex: "5", "6", "1")!
pieceMark = parts[0]; pieceMark = parts[0];
assemblyMark = parts[0]; assemblyMark = parts[0];
profile = parts.slice(1).join('_'); profile = parts.slice(1).join('_');
@@ -100,7 +110,7 @@ function parseWrlNodeName(
// CASO 3: Prefixo de conjunto alfanumérico (ex: "V101_1_CHAPA", "P1_2_CANTON") // CASO 3: Prefixo de conjunto alfanumérico (ex: "V101_1_CHAPA", "P1_2_CANTON")
else if (parts.length >= 2 && /^[A-Za-z]+\d+$/i.test(parts[0])) { else if (parts.length >= 2 && /^[A-Za-z]+\d+$/i.test(parts[0])) {
assemblyMark = parts[0]; assemblyMark = parts[0];
pieceMark = parts[0]; // ou parts[1] pieceMark = parts[0];
profile = parts.slice(1).join('_'); profile = parts.slice(1).join('_');
} }
// CASO 4: Parafusos / Fixadores // CASO 4: Parafusos / Fixadores
@@ -113,18 +123,32 @@ function parseWrlNodeName(
pieceMark = pieceMark.replace(/^(?:Part|Piece|Peca|Elem|Item|Member|Profile|Mesh)[-_]/i, ''); pieceMark = pieceMark.replace(/^(?:Part|Piece|Peca|Elem|Item|Member|Profile|Mesh)[-_]/i, '');
} }
// Normalização do Perfil / Bitola (ex: W360X44_0 -> W360X44.0, W360X32_9 -> W360X32.9)
if (profile) {
profile = profile.replace(/_(\d+)$/, '.$1').replace(/_/g, ' ').trim();
}
let cleanMark = pieceMark.replace(/[-_.#]\d+$/, '').trim().toUpperCase(); let cleanMark = pieceMark.replace(/[-_.#]\d+$/, '').trim().toUpperCase();
if (!cleanMark) cleanMark = pieceMark.toUpperCase(); if (!cleanMark) cleanMark = pieceMark.toUpperCase();
const cleanAssemblyMark = assemblyMark ? assemblyMark.replace(/[-_.#]\d+$/, '').trim().toUpperCase() : cleanMark; const cleanAssemblyMark = assemblyMark ? assemblyMark.replace(/[-_.#]\d+$/, '').trim().toUpperCase() : cleanMark;
// Em estruturas metálicas, a marca da peça apontada na produção é a marca do conjunto (Assembly Mark).
// Se a sub-peça for um número de corte (>= 1000) e houver conjunto pai, a marca principal é o conjunto!
let mainMark = pieceMark;
let cleanMainMark = cleanMark;
if (assemblyMark && (isSinglePartCutNumber(pieceMark) || !mainMark || mainMark === 'indefinido')) {
mainMark = assemblyMark;
cleanMainMark = cleanAssemblyMark;
}
// Detecção de tipo de elemento estrutural // Detecção de tipo de elemento estrutural
let detectedType = 'ELEMENT'; let detectedType = 'ELEMENT';
const searchStr = `${profile} ${name} ${cleanMark} ${cleanAssemblyMark}`.toUpperCase(); const searchStr = `${profile} ${name} ${cleanMark} ${cleanAssemblyMark} ${mainMark}`.toUpperCase();
if (searchStr.includes('VIGA') || searchStr.includes('BEAM') || cleanMark.startsWith('V')) { if (searchStr.includes('VIGA') || searchStr.includes('BEAM') || cleanMainMark.startsWith('V')) {
detectedType = 'BEAM'; detectedType = 'BEAM';
} else if (searchStr.includes('COLUNA') || searchStr.includes('PILAR') || searchStr.includes('COLUMN') || cleanMark.startsWith('P')) { } else if (searchStr.includes('COLUNA') || searchStr.includes('PILAR') || searchStr.includes('COLUMN') || cleanMainMark.startsWith('P')) {
detectedType = 'COLUMN'; detectedType = 'COLUMN';
} else if (searchStr.includes('CHAPA') || searchStr.includes('PLATE') || cleanMark.startsWith('PL') || cleanMark.startsWith('CH')) { } else if (searchStr.includes('CHAPA') || searchStr.includes('PLATE') || cleanMark.startsWith('PL') || cleanMark.startsWith('CH')) {
detectedType = 'PLATE'; detectedType = 'PLATE';
@@ -145,10 +169,15 @@ function parseWrlNodeName(
} }
return { return {
mainMark: mainMark.toUpperCase(),
cleanMainMark: cleanMainMark.toUpperCase(),
pieceMark: pieceMark.toUpperCase(), pieceMark: pieceMark.toUpperCase(),
cleanMark, cleanMark,
assemblyMark: assemblyMark ? assemblyMark.toUpperCase() : undefined, assemblyMark: assemblyMark ? assemblyMark.toUpperCase() : undefined,
cleanAssemblyMark, cleanAssemblyMark,
singlePartMark,
profile: profile || undefined,
section: profile || undefined,
phase: inheritedPhase, phase: inheritedPhase,
detectedType, detectedType,
}; };
@@ -503,27 +532,36 @@ export async function loadAndAuditWRL(
} }
mesh.material = material; mesh.material = material;
const pieceSection = parsed.profile || parsed.section || parsed.cleanMainMark || 'Perfil';
const mainMark = parsed.mainMark || parsed.cleanMainMark || parsed.pieceMark;
const cleanMain = parsed.cleanMainMark || parsed.cleanMark;
const pieceInfo: PieceInfo = { const pieceInfo: PieceInfo = {
expressID, expressID,
guid, guid,
name: rawNodeName, name: rawNodeName,
type: parsed.detectedType, type: parsed.detectedType,
pieceMark: parsed.pieceMark, pieceMark: mainMark,
cleanMark: parsed.cleanMark, cleanMark: cleanMain,
assemblyMark: parsed.assemblyMark, assemblyMark: parsed.assemblyMark || mainMark,
cleanAssemblyMark: parsed.cleanAssemblyMark, cleanAssemblyMark: parsed.cleanAssemblyMark || cleanMain,
section: pieceSection,
phase: parsed.phase, phase: parsed.phase,
singlePartMark: parsed.singlePartMark,
mesh, mesh,
}; };
// Injeta metadados essenciais no userData do Mesh // Injeta metadados essenciais no userData do Mesh (com total compatibilidade IFC / Three.js)
mesh.userData = { mesh.userData = {
ifcId: expressID, // Identificador primordial compatível com Raycaster, Hover e Seleção do visualizador
expressID, expressID,
guid, guid,
pieceMark: parsed.pieceMark, pieceMark: mainMark, // Peça principal da produção (ex: 1, 8, V101)
cleanMark: parsed.cleanMark, cleanMark: cleanMain,
assemblyMark: parsed.assemblyMark, singlePartMark: parsed.singlePartMark, // Componente de corte (ex: 1001)
cleanAssemblyMark: parsed.cleanAssemblyMark, assemblyMark: parsed.assemblyMark || mainMark,
cleanAssemblyMark: parsed.cleanAssemblyMark || cleanMain,
section: pieceSection,
phase: parsed.phase, phase: parsed.phase,
type: parsed.detectedType, type: parsed.detectedType,
name: rawNodeName, name: rawNodeName,
@@ -533,17 +571,12 @@ export async function loadAndAuditWRL(
pieceByExpressID.set(expressID, pieceInfo); pieceByExpressID.set(expressID, pieceInfo);
// Agrupa peças por marca // Agrupa peças por marca de conjunto e por marca de componente
const markKey = parsed.cleanMark || parsed.pieceMark; const markKeys = [mainMark, cleanMain, parsed.singlePartMark, parsed.pieceMark, parsed.cleanMark].filter(Boolean) as string[];
if (markKey) { for (const mKey of Array.from(new Set(markKeys))) {
const list = piecesByMark.get(markKey) || []; const list = piecesByMark.get(mKey) || [];
list.push(pieceInfo); list.push(pieceInfo);
piecesByMark.set(markKey, list); piecesByMark.set(mKey, list);
}
if (parsed.cleanAssemblyMark && parsed.cleanAssemblyMark !== markKey) {
const listAss = piecesByMark.get(parsed.cleanAssemblyMark) || [];
listAss.push(pieceInfo);
piecesByMark.set(parsed.cleanAssemblyMark, listAss);
} }
} }
}); });
+1
View File
@@ -33,6 +33,7 @@ export interface PieceInfo {
parentAssemblyID?: number; parentAssemblyID?: number;
phase?: string; phase?: string;
ofNumber?: string; ofNumber?: string;
singlePartMark?: string;
section?: string; section?: string;
span?: number; span?: number;
material?: string; material?: string;
+822
View File
@@ -0,0 +1,822 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { loadAndAuditModel3D, LoadedModel3DResult } from '@/lib/3d/modelLoaderService';
import { fetchSavedIFCModel } from '@/lib/ifc/ifcStorageService';
import { AnalyzerViewer3D } from '@/components/viewer3d/AnalyzerViewer3D';
import { PieceAuditDetailModal } from '@/components/viewer3d/PieceAuditDetailModal';
import { AuditEngine } from '@/lib/3d/analyzer/auditEngine';
import { AuditReportService } from '@/lib/3d/analyzer/auditReportService';
import { DefectItem, DefectType, DefectSeverity, ColorizationMode, ComparisonDiffSummary } from '@/types/analisador3d';
import { useToast } from '@/hooks/use-toast';
import { usePermissions } from '@/hooks/usePermissions';
import {
ScanSearch,
Upload,
FileSpreadsheet,
FileText,
Layers,
Filter,
AlertTriangle,
AlertCircle,
CheckCircle2,
RefreshCw,
Search,
Crosshair,
Factory,
Maximize2,
Minimize2,
Boxes,
ShieldAlert,
SlidersHorizontal,
ChevronDown,
Info,
Wrench,
Sparkles,
Weight
} from 'lucide-react';
interface OFOption {
id: string;
of_number: string;
descritivo?: string;
peso_total?: number;
ifc_url?: string | null;
ifc_filename?: string | null;
model_3d_rotation?: { x: number; y: number; z: number } | null;
}
export default function Analisador3D() {
const { toast } = useToast();
const { checkResourceAccess } = usePermissions();
// Estados principais de dados
const [ofs, setOfs] = useState<OFOption[]>([]);
const [selectedOF, setSelectedOF] = useState<string>('');
const [selectedPhase, setSelectedPhase] = useState<string>('all');
const [phasesList, setPhasesList] = useState<string[]>([]);
// Modos de Operação
const [analysisMode, setAnalysisMode] = useState<'auditoria' | 'comparacao'>('auditoria');
const [colorizationMode, setColorizationMode] = useState<ColorizationMode>('status_defeito');
const [isGhostMode, setIsGhostMode] = useState<boolean>(true);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(() =>
typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false
);
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDarkTheme(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
// Modelos 3D Carregados
const [modelBase, setModelBase] = useState<LoadedModel3DResult | null>(null);
const [modelRevisao, setModelRevisao] = useState<LoadedModel3DResult | null>(null);
const [isLoadingModel, setIsLoadingModel] = useState<boolean>(false);
const [loadingStep, setLoadingStep] = useState<string>('');
const [loadingPercent, setLoadingPercent] = useState<number>(0);
// Dados de Produção e Auditoria
const [productionMap, setProductionMap] = useState<Map<string, any>>(new Map());
const [defects, setDefects] = useState<DefectItem[]>([]);
const [summary, setSummary] = useState<ComparisonDiffSummary | null>(null);
// Filtros e Seleção
const [filterSeverity, setFilterSeverity] = useState<string>('all');
const [filterType, setFilterType] = useState<string>('all');
const [filterProcess, setFilterProcess] = useState<string>('all');
const [filterAssembly, setFilterAssembly] = useState<string>('all');
const [searchPiece, setSearchPiece] = useState<string>('');
const [selectedDefect, setSelectedDefect] = useState<DefectItem | null>(null);
const [selectedPieceMark, setSelectedPieceMark] = useState<string | null>(null);
const [detailModalDefect, setDetailModalDefect] = useState<DefectItem | null>(null);
const fileInputBaseRef = useRef<HTMLInputElement>(null);
const fileInputRevRef = useRef<HTMLInputElement>(null);
// 1. Carrega OFs do Supabase
useEffect(() => {
async function fetchOFs() {
try {
const { data, error } = await supabase
.from('ordens_fabricacao' as any)
.select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename, model_3d_rotation')
.order('num_of', { ascending: false });
if (!error && data) {
const list: OFOption[] = (data as any[]).map((item) => ({
id: item.id,
of_number: item.num_of || '',
descritivo: item.descritivo || '',
peso_total: Number(item.peso_total || 0),
ifc_url: item.ifc_url || null,
ifc_filename: item.ifc_filename || null,
model_3d_rotation: item.model_3d_rotation || null,
}));
setOfs(list);
if (list.length > 0 && !selectedOF) {
setSelectedOF(list[0].of_number);
}
}
} catch (err) {
console.error('Erro ao buscar OFs:', err);
}
}
fetchOFs();
}, []);
// 2. Busca apontamentos e peças da OF para alimentar o cruzamento fabril
useEffect(() => {
if (!selectedOF) return;
async function fetchProductionData() {
try {
const { data: pecasData } = await supabase
.from('pecas' as any)
.select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material')
.eq('of_number', selectedOF);
const { data: apontamentosData } = await supabase
.from('apontamentos_producao' as any)
.select(`
id, of_number, peca_id, quantidade_produzida,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`)
.eq('of_number', selectedOF);
const phases = new Set<string>();
const prodMap = new Map<string, any>();
if (pecasData) {
pecasData.forEach((p: any) => {
const fase = String(p.etapa_fase || '').trim();
if (fase) phases.add(fase);
const marcaStr = String(p.marca || '').trim();
const item = {
marca: marcaStr,
fase,
totalQtd: Number(p.quantidade || 1),
pesoTotal: Number(p.peso_total || 0),
pesoUnitario: Number(p.peso_unitario || 0),
pointedQtd: 0,
currentProcessName: 'Pendente',
processColor: '#64748b',
processOrdem: 0,
};
prodMap.set(marcaStr, item);
prodMap.set(marcaStr.toUpperCase(), item);
});
}
if (apontamentosData) {
apontamentosData.forEach((ap: any) => {
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
if (marca && prodMap.has(marca)) {
const item = prodMap.get(marca);
const qty = Number(ap.quantidade_produzida || 0);
const procName = String(ap.processo?.nome || '').trim();
if (qty > 0 && !procName.toLowerCase().includes('detalh')) {
item.pointedQtd = Math.max(item.pointedQtd, qty);
item.currentProcessName = procName;
item.processColor = ap.processo?.cor || '#10b981';
item.processOrdem = Number(ap.processo?.ordem || 0);
}
}
});
}
setPhasesList(Array.from(phases).sort());
setProductionMap(prodMap);
} catch (err) {
console.error('Erro ao buscar dados de produção:', err);
}
}
fetchProductionData();
}, [selectedOF]);
// 3. Carregamento Automático do Modelo Salvo da OF
const loadSavedOFModel = useCallback(async (ofNumber: string) => {
const currentOF = ofs.find((o) => o.of_number === ofNumber);
if (!currentOF || !currentOF.ifc_url) return;
setIsLoadingModel(true);
setLoadingStep('Buscando modelo 3D salvo no banco...');
setLoadingPercent(20);
try {
const buffer = await fetchSavedIFCModel(ofNumber, currentOF.ifc_url, (pct, step) => {
setLoadingPercent(pct);
setLoadingStep(step);
});
setLoadingStep('Processando geometria e montagens...');
setLoadingPercent(60);
const result = await loadAndAuditModel3D(buffer, currentOF.ifc_filename || undefined, (pct, step) => {
setLoadingPercent(60 + Math.round(pct * 0.4));
setLoadingStep(step);
});
setModelBase(result);
toast({
title: 'Modelo 3D Carregado',
description: `Modelo da OF ${ofNumber} pronto para auditoria.`,
});
} catch (err: any) {
console.error('Falha ao carregar modelo da OF:', err);
toast({
variant: 'destructive',
title: 'Falha no Carregamento',
description: 'Não foi possível carregar o modelo salvo no banco.',
});
} finally {
setIsLoadingModel(false);
}
}, [ofs, toast]);
useEffect(() => {
if (selectedOF) {
loadSavedOFModel(selectedOF);
}
}, [selectedOF, loadSavedOFModel]);
// 4. Executa a Auditoria Geométrica sempre que os modelos ou a OF mudam
useEffect(() => {
if (!modelBase) {
setDefects([]);
setSummary(null);
return;
}
const result = AuditEngine.runAudit({
modelBase,
modelRevisao: analysisMode === 'comparacao' ? modelRevisao : null,
productionMap,
});
setDefects(result.defects);
setSummary(result.summary);
}, [modelBase, modelRevisao, analysisMode, productionMap]);
// 5. Upload manual de modelos locais (para testes rápidos de IFC ou WRM)
const handleUploadLocalModel = async (file: File, isRev: boolean = false) => {
setIsLoadingModel(true);
setLoadingStep(`Lendo arquivo ${file.name}...`);
setLoadingPercent(30);
try {
const result = await loadAndAuditModel3D(file, file.name, (pct, step) => {
setLoadingPercent(30 + Math.round(pct * 0.7));
setLoadingStep(step);
});
if (isRev) {
setModelRevisao(result);
setAnalysisMode('comparacao');
toast({
title: 'Revisão Carregada',
description: `Modelo de revisão ${file.name} integrado para comparação.`,
});
} else {
setModelBase(result);
toast({
title: 'Modelo Base Carregado',
description: `Modelo base ${file.name} carregado com sucesso.`,
});
}
} catch (err: any) {
toast({
variant: 'destructive',
title: 'Erro no Arquivo',
description: err.message || 'Falha ao processar o arquivo 3D.',
});
} finally {
setIsLoadingModel(false);
}
};
// 6. Lista Filtrada de Ocorrências
const filteredDefects = useMemo(() => {
return defects.filter((d) => {
if (filterSeverity !== 'all' && d.gravidade !== filterSeverity) return false;
if (filterType !== 'all' && d.tipo !== filterType) return false;
if (filterProcess !== 'all' && d.processoAtual?.toLowerCase() !== filterProcess.toLowerCase()) return false;
if (filterAssembly !== 'all' && d.conjuntoMarca !== filterAssembly) return false;
if (searchPiece) {
const query = searchPiece.toLowerCase().trim();
const matchMarca = d.pecaMarca.toLowerCase().includes(query);
const matchConjunto = d.conjuntoMarca?.toLowerCase().includes(query);
const matchConectada = d.pecaConectadaMarca?.toLowerCase().includes(query);
if (!matchMarca && !matchConjunto && !matchConectada) return false;
}
return true;
});
}, [defects, filterSeverity, filterType, filterProcess, filterAssembly, searchPiece]);
// 7. Lista de Conjuntos disponíveis para filtro
const assembliesList = useMemo(() => {
const set = new Set<string>();
defects.forEach((d) => {
if (d.conjuntoMarca) set.add(d.conjuntoMarca);
});
return Array.from(set).sort();
}, [defects]);
// 8. Disparo de Relatórios
const handleExportPDF = () => {
if (!summary) return;
const currentOF = ofs.find((o) => o.of_number === selectedOF);
AuditReportService.exportToPDF({
ofNumber: selectedOF || 'GERAL',
ofDescritivo: currentOF?.descritivo,
dataAnalise: new Date().toLocaleDateString('pt-BR'),
analisadoPor: 'Controle de Qualidade TrackSteel',
modoAnalise: analysisMode,
modeloBaseNome: currentOF?.ifc_filename || 'Modelo Base',
modeloRevisaoNome: modelRevisao ? 'Revisão Projeto' : undefined,
summary,
defects: filteredDefects.length > 0 ? filteredDefects : defects,
});
};
const handleExportExcel = () => {
if (!summary) return;
const currentOF = ofs.find((o) => o.of_number === selectedOF);
AuditReportService.exportToExcel({
ofNumber: selectedOF || 'GERAL',
ofDescritivo: currentOF?.descritivo,
dataAnalise: new Date().toLocaleDateString('pt-BR'),
analisadoPor: 'Engenharia TrackSteel',
modoAnalise: analysisMode,
summary,
defects: filteredDefects.length > 0 ? filteredDefects : defects,
});
};
const currentOFObj = ofs.find((o) => o.of_number === selectedOF);
return (
<div
className={`flex flex-col h-[calc(100vh-4rem)] overflow-hidden transition-colors ${
isFullscreen ? 'fixed inset-0 z-50 bg-slate-950 p-3' : 'p-4'
}`}
>
{/* ── BARRA SUPERIOR: SELEÇÕES E AÇÕES ── */}
<div className="flex flex-wrap items-center justify-between gap-3 p-3.5 mb-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="p-2 bg-indigo-600 text-white rounded-lg shadow-md shadow-indigo-500/20">
<ScanSearch className="w-5 h-5" />
</span>
<div>
<h1 className="text-base font-bold text-slate-900 dark:text-white flex items-center gap-2">
Analisador 3D BIM & QA/QC
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
TrackSteel Engine
</span>
</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">
Auditoria de conexões, furações coaxiais e comparador de revisões IFC / WRM
</p>
</div>
</div>
<div className="h-6 w-[1px] bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
{/* Seletor de OF */}
<div className="flex items-center gap-2">
<label className="text-xs font-semibold text-slate-600 dark:text-slate-400">OF:</label>
<select
value={selectedOF}
onChange={(e) => setSelectedOF(e.target.value)}
className="px-3 py-1.5 text-xs font-semibold bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Selecione uma OF...</option>
{ofs.map((o) => (
<option key={o.id} value={o.of_number}>
OF {o.of_number} {o.descritivo ? `— ${o.descritivo.slice(0, 25)}` : ''}
</option>
))}
</select>
</div>
{/* Alternância de Modo */}
<div className="flex items-center p-1 bg-slate-100 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 text-xs font-medium">
<button
onClick={() => setAnalysisMode('auditoria')}
className={`px-3 py-1 rounded-md transition ${
analysisMode === 'auditoria'
? 'bg-white dark:bg-slate-900 text-indigo-600 dark:text-indigo-400 shadow-sm font-semibold'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
Auditoria Geométrica (QA)
</button>
<button
onClick={() => setAnalysisMode('comparacao')}
className={`px-3 py-1 rounded-md transition ${
analysisMode === 'comparacao'
? 'bg-white dark:bg-slate-900 text-indigo-600 dark:text-indigo-400 shadow-sm font-semibold'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
Comparador de Revisões (Diff)
</button>
</div>
</div>
{/* Botões de Ações e Exportações */}
<div className="flex items-center gap-2">
{/* Uploads Locais de Teste */}
<input
ref={fileInputBaseRef}
type="file"
accept=".ifc,.wrl,.wrz,.vrml"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], false);
}}
/>
<input
ref={fileInputRevRef}
type="file"
accept=".ifc,.wrl,.wrz,.vrml"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], true);
}}
/>
<button
onClick={() => fileInputBaseRef.current?.click()}
title="Carregar arquivo 3D local (IFC ou WRL)"
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-lg text-xs font-medium transition"
>
<Upload className="w-3.5 h-3.5" />
<span className="hidden md:inline">Carregar Modelo</span>
</button>
{analysisMode === 'comparacao' && (
<button
onClick={() => fileInputRevRef.current?.click()}
title="Carregar Revisão B para comparar com a Base"
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800 rounded-lg text-xs font-semibold hover:bg-indigo-100 dark:hover:bg-indigo-900 transition"
>
<Boxes className="w-3.5 h-3.5" />
<span>+ Revisão B</span>
</button>
)}
<div className="h-5 w-[1px] bg-slate-200 dark:bg-slate-800 mx-1" />
{/* Exportações */}
<button
onClick={handleExportPDF}
disabled={!summary}
title="Exportar Relatório Técnico em PDF"
className="flex items-center gap-1.5 px-3 py-1.5 bg-rose-50 dark:bg-rose-950/40 text-rose-700 dark:text-rose-400 border border-rose-200 dark:border-rose-900/50 hover:bg-rose-100 dark:hover:bg-rose-900/60 rounded-lg text-xs font-semibold transition disabled:opacity-50"
>
<FileText className="w-3.5 h-3.5" />
<span className="hidden sm:inline">PDF</span>
</button>
<button
onClick={handleExportExcel}
disabled={!summary}
title="Exportar Planilha Excel com Peças Afetadas"
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-900/50 hover:bg-emerald-100 dark:hover:bg-emerald-900/60 rounded-lg text-xs font-semibold transition disabled:opacity-50"
>
<FileSpreadsheet className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Excel</span>
</button>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? 'Reduzir' : 'Modo Expandido'}
className="p-2 text-slate-500 hover:text-slate-900 dark:hover:text-white rounded-lg transition"
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
</div>
</div>
{/* ── CARDS DE KPIS / INDICADORES ── */}
{summary && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5 mb-3">
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 block">Total Peças</span>
<div className="text-lg font-bold text-slate-900 dark:text-white mt-0.5">{summary.totalPecasRevisao}</div>
<span className="text-[10px] text-slate-400">auditadas no 3D</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-red-200 dark:border-red-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Críticos
</span>
<div className="text-lg font-bold text-red-600 dark:text-red-400 mt-0.5">{summary.inconformidadesCriticas}</div>
<span className="text-[10px] text-red-500/80">furo cego / colisão</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-amber-200 dark:border-amber-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Alertas Furo
</span>
<div className="text-lg font-bold text-amber-600 dark:text-amber-400 mt-0.5">{summary.alertasFuros}</div>
<span className="text-[10px] text-amber-500/80">desalinhados / excêntricos</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-indigo-200 dark:border-indigo-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-indigo-600 dark:text-indigo-400 block">Modificadas / Novas</span>
<div className="text-lg font-bold text-indigo-600 dark:text-indigo-400 mt-0.5">
{summary.modificadas} <span className="text-xs font-normal text-slate-400">/ +{summary.novas}</span>
</div>
<span className="text-[10px] text-slate-400">variação de projeto</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Weight className="w-3 h-3" />
Peso Afetado
</span>
<div className="text-lg font-bold text-slate-900 dark:text-white mt-0.5">{summary.pesoAfetadoKg.toFixed(0)} kg</div>
<span className="text-[10px] text-slate-400">em peças com ocorrência</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Factory className="w-3 h-3" />
Risco Fábrica
</span>
<div
className={`text-sm font-bold uppercase mt-1 px-2 py-0.5 rounded text-center ${
summary.riscoGeral === 'critico'
? 'bg-red-500/10 text-red-500'
: summary.riscoGeral === 'alto'
? 'bg-amber-500/10 text-amber-500'
: 'bg-emerald-500/10 text-emerald-500'
}`}
>
{summary.riscoGeral}
</div>
<span className="text-[10px] text-slate-400 text-center block mt-0.5">retrabalho potencial</span>
</div>
</div>
)}
{/* ── ÁREA PRINCIPAL DIVIDIDA (SPLIT RESPONSIVO) ── */}
<div className="flex-1 flex flex-col lg:flex-row gap-3 min-h-0 overflow-hidden">
{/* PAINEL LATERAL ESQUERDO: FILTROS & LISTA DE OCORRÊNCIAS */}
<div className="w-full lg:w-[420px] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden">
{/* Barra de Filtros */}
<div className="p-3 border-b border-slate-200 dark:border-slate-800 space-y-2.5 bg-slate-50/50 dark:bg-slate-900/50">
{/* Campo de Busca por Peça */}
<div className="relative">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-2.5" />
<input
type="text"
placeholder="Buscar por marca da peça (ex: CH-01, V-101)..."
value={searchPiece}
onChange={(e) => setSearchPiece(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-xs bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{searchPiece && (
<button
onClick={() => setSearchPiece('')}
className="absolute right-2.5 top-2 text-xs text-slate-400 hover:text-slate-600 dark:hover:text-white"
>
×
</button>
)}
</div>
{/* Filtros em Grade */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<label className="text-[10px] font-semibold text-slate-500 dark:text-slate-400 block mb-0.5">
Severidade
</label>
<select
value={filterSeverity}
onChange={(e) => setFilterSeverity(e.target.value)}
className="w-full px-2 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 text-xs"
>
<option value="all">Todas ({defects.length})</option>
<option value="critico">Crítico</option>
<option value="alerta">Alerta</option>
<option value="informativo">Informativo</option>
</select>
</div>
<div>
<label className="text-[10px] font-semibold text-slate-500 dark:text-slate-400 block mb-0.5">
Tipo de Ocorrência
</label>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="w-full px-2 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 text-xs"
>
<option value="all">Todos os tipos</option>
<option value="furo_cego_faltante">Furo Cego (Falta Contra-Furo)</option>
<option value="deslocamento_furo">Furo Deslocado</option>
<option value="furo_extra_revisao">Novo Furo Adicionado</option>
<option value="furo_removido_revisao">Furo Removido</option>
<option value="colisao_geometrica">Colisão / Clash</option>
<option value="peca_excluida">Peça Excluída</option>
<option value="peca_nova">Peça Nova</option>
</select>
</div>
</div>
{/* Seletor de Modo de Cores 3D */}
<div className="pt-1 flex items-center justify-between text-xs">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">Colorização 3D:</span>
<div className="flex items-center gap-1">
<button
onClick={() => setColorizationMode('status_defeito')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'status_defeito'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Defeito
</button>
<button
onClick={() => setColorizationMode('diff_revisao')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'diff_revisao'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Revisão
</button>
<button
onClick={() => setColorizationMode('risco_fabril')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'risco_fabril'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Risco Fábrica
</button>
</div>
</div>
</div>
{/* Lista de Ocorrências (Scrollável) */}
<div className="flex-1 overflow-y-auto p-2.5 space-y-2">
{isLoadingModel ? (
<div className="flex flex-col items-center justify-center h-48 text-center p-4">
<RefreshCw className="w-7 h-7 text-indigo-500 animate-spin mb-2" />
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">{loadingStep}</span>
<span className="text-[11px] text-slate-400 mt-1">{loadingPercent}% processado</span>
</div>
) : filteredDefects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center p-4 text-slate-400">
<CheckCircle2 className="w-8 h-8 text-emerald-500 mb-2" />
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">
Nenhuma inconformidade encontrada
</p>
<p className="text-[11px] text-slate-400 mt-0.5">
Todas as furações e geometrias conferem com os parâmetros e filtros ativos.
</p>
</div>
) : (
filteredDefects.map((defect) => {
const isSelected = selectedDefect?.id === defect.id || selectedPieceMark === defect.pecaMarca;
return (
<div
key={defect.id}
onClick={() => {
setSelectedDefect(defect);
setSelectedPieceMark(defect.pecaMarca);
}}
className={`p-3 rounded-lg border transition-all cursor-pointer ${
isSelected
? 'bg-indigo-50 dark:bg-indigo-950/40 border-indigo-500 shadow-md ring-1 ring-indigo-500'
: 'bg-white dark:bg-slate-800/80 border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-slate-900 dark:text-white text-xs bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded">
{defect.pecaMarca}
</span>
{defect.conjuntoMarca && (
<span className="text-[10px] font-mono text-slate-500 dark:text-slate-400">
(Conj: {defect.conjuntoMarca})
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{defect.gravidade === 'critico' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-100 dark:bg-red-950/60 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
CRÍTICO
</span>
)}
{defect.gravidade === 'alerta' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-amber-100 dark:bg-amber-950/60 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-800">
ALERTA
</span>
)}
{defect.gravidade === 'informativo' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-100 dark:bg-blue-950/60 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
NOVO
</span>
)}
</div>
</div>
<h4 className="text-xs font-semibold text-slate-800 dark:text-slate-200 mb-1 leading-tight">
{defect.titulo}
</h4>
<p className="text-[11px] text-slate-600 dark:text-slate-400 line-clamp-2 leading-relaxed mb-2">
{defect.descricao}
</p>
{/* Rodapé do Card com Status Fabril e Ação */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-slate-700/60 text-[11px]">
<div className="flex items-center gap-1.5">
<Factory className="w-3 h-3 text-slate-400" />
<span className="text-slate-500 dark:text-slate-400">
{defect.processoAtual || 'Pendente'}
</span>
<span
className={`text-[10px] font-bold uppercase px-1 rounded ${
defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto'
? 'text-red-500 bg-red-50 dark:bg-red-950/50'
: 'text-slate-400'
}`}
>
Risco: {defect.riscoFabril}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setDetailModalDefect(defect);
}}
className="text-indigo-600 dark:text-indigo-400 hover:underline font-semibold text-[11px]"
>
Ver Detalhes
</button>
</div>
</div>
);
})
)}
</div>
</div>
{/* VISUALIZADOR 3D CENTRAL */}
<div className="flex-1 flex flex-col min-h-[350px] relative">
<AnalyzerViewer3D
modelData={modelBase}
defects={defects}
selectedDefect={selectedDefect}
selectedPieceMark={selectedPieceMark}
colorizationMode={colorizationMode}
onSelectDefect={(def) => setSelectedDefect(def)}
onSelectPiece={(mark) => setSelectedPieceMark(mark)}
isGhostMode={isGhostMode}
onToggleGhostMode={() => setIsGhostMode(!isGhostMode)}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
theme={isDarkTheme ? 'dark' : 'light'}
selectedOF={selectedOF}
initialRotation={currentOFObj?.model_3d_rotation || null}
/>
</div>
</div>
{/* MODAL DETALHADO DA PEÇA AUDITADA */}
<PieceAuditDetailModal
isOpen={!!detailModalDefect}
onClose={() => setDetailModalDefect(null)}
defect={detailModalDefect}
onFocus3D={(coords) => {
if (detailModalDefect) {
setSelectedDefect(detailModalDefect);
setSelectedPieceMark(detailModalDefect.pecaMarca);
}
}}
/>
</div>
);
}
+29 -2
View File
@@ -3,10 +3,11 @@ import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ClipboardList, BarChart3, Settings } from 'lucide-react'; import { ClipboardList, BarChart3, Settings, Flame } from 'lucide-react';
import { ApontamentoForm } from '@/components/apontamento/ApontamentoForm'; import { ApontamentoForm } from '@/components/apontamento/ApontamentoForm';
import { ApontamentosListOtimizado } from '@/components/apontamento/ApontamentosListOtimizado'; import { ApontamentosListOtimizado } from '@/components/apontamento/ApontamentosListOtimizado';
import { ProcessosList } from '@/components/apontamento/ProcessosList'; import { ProcessosList } from '@/components/apontamento/ProcessosList';
import { MapaTermicoKitsView } from '@/components/apontamento/kits/MapaTermicoKitsView';
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao'; import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { usePermissionControl } from '@/hooks/usePermissionControl'; import { usePermissionControl } from '@/hooks/usePermissionControl';
@@ -34,7 +35,7 @@ const ApontamentoProducao = () => {
<TabsList className={`w-full bg-muted border-border ${ <TabsList className={`w-full bg-muted border-border ${
isMobile isMobile
? 'grid grid-cols-1 gap-1 h-auto p-1' ? 'grid grid-cols-1 gap-1 h-auto p-1'
: 'grid grid-cols-3 h-10' : 'grid grid-cols-4 h-10'
}`}> }`}>
<TabsTrigger <TabsTrigger
value="apontamento" value="apontamento"
@@ -45,6 +46,15 @@ const ApontamentoProducao = () => {
<ClipboardList className="h-4 w-4" /> <ClipboardList className="h-4 w-4" />
Novo Apontamento Novo Apontamento
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="mapa-kits"
className={`flex items-center gap-2 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground ${
isMobile ? 'w-full justify-start p-3 text-sm' : ''
}`}
>
<Flame className="h-4 w-4 text-orange-500 fill-orange-500 animate-pulse" />
Mapa de Kits
</TabsTrigger>
<TabsTrigger <TabsTrigger
value="historico" value="historico"
className={`flex items-center gap-2 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground ${ className={`flex items-center gap-2 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground ${
@@ -89,6 +99,23 @@ const ApontamentoProducao = () => {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="mapa-kits" className="space-y-4 sm:space-y-6 mt-4">
<Card className="bg-card border-border shadow-sm">
<CardHeader className="pb-3 border-b border-border/60">
<CardTitle className="text-card-foreground flex items-center gap-2 text-lg sm:text-xl font-bold">
<Flame className="h-5 w-5 text-orange-500 fill-orange-500 animate-pulse" />
<span>Mapa Térmico de Kits de Montagem</span>
</CardTitle>
<p className="text-xs sm:text-sm text-muted-foreground">
Visão integrada do balanceamento do corte de componentes e liberação de kits completos para montagem/solda
</p>
</CardHeader>
<CardContent className="pt-4">
<MapaTermicoKitsView />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="historico" className="space-y-4 sm:space-y-6 mt-4"> <TabsContent value="historico" className="space-y-4 sm:space-y-6 mt-4">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
+12
View File
@@ -15,6 +15,7 @@ import { FichaTecnicaPreview } from '@/components/forms/FichaTecnicaPreview';
import { useThemeConfig } from '@/hooks/useThemeConfig'; import { useThemeConfig } from '@/hooks/useThemeConfig';
import { useFichaTecnica, FichaTecnicaData } from '@/hooks/useFichaTecnica'; import { useFichaTecnica, FichaTecnicaData } from '@/hooks/useFichaTecnica';
import { useUserManagement } from '@/hooks/useUserManagement'; import { useUserManagement } from '@/hooks/useUserManagement';
import { usePermissions } from '@/hooks/usePermissions';
interface InfoCalculoEstrutural { interface InfoCalculoEstrutural {
e: boolean; e: boolean;
@@ -179,6 +180,7 @@ const CadastroOF = () => {
const { themeConfig } = useThemeConfig(); const { themeConfig } = useThemeConfig();
const { buscarFichaTecnica, salvarFichaTecnica, loading: fichaTecnicaLoading } = useFichaTecnica(); const { buscarFichaTecnica, salvarFichaTecnica, loading: fichaTecnicaLoading } = useFichaTecnica();
const { users } = useUserManagement(); const { users } = useUserManagement();
const { canRegra } = usePermissions();
const [formData, setFormData] = useState<FormData>(initialFormData); const [formData, setFormData] = useState<FormData>(initialFormData);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -288,6 +290,16 @@ const CadastroOF = () => {
return; return;
} }
if (!isEditing && !canRegra('eng_criar_of')) {
toast.error('Atenção: Você não possui permissão para cadastrar novas Ordens de Fabricação.');
return;
}
if (isEditing && !canRegra('eng_editar_of')) {
toast.error('Atenção: Você não possui permissão para alterar dados desta Ordem de Fabricação.');
return;
}
setIsSaving(true); setIsSaving(true);
try { try {
+30
View File
@@ -9,6 +9,9 @@ import { usePecasP4Listener } from '@/hooks/usePecasP4Listener';
import { PecaForm } from '@/components/pecas/PecaForm'; import { PecaForm } from '@/components/pecas/PecaForm';
import { PecasTable } from '@/components/pecas/PecasTable'; import { PecasTable } from '@/components/pecas/PecasTable';
import { ComponentesPopup } from '@/components/pecas/ComponentesPopup'; import { ComponentesPopup } from '@/components/pecas/ComponentesPopup';
import { RevisaoPecaModal } from '@/components/pecas/revisoes/RevisaoPecaModal';
import { PainelRevisoesOFModal } from '@/components/pecas/revisoes/PainelRevisoesOFModal';
import { useRevisoes } from '@/hooks/useRevisoes';
import { Package, Plus, RefreshCw } from 'lucide-react'; import { Package, Plus, RefreshCw } from 'lucide-react';
import { usePermissionControl } from '@/hooks/usePermissionControl'; import { usePermissionControl } from '@/hooks/usePermissionControl';
@@ -39,6 +42,8 @@ export default function CadastroPecas() {
const [editingPeca, setEditingPeca] = useState<Peca | null>(null); const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null); const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
const [pecaParaRevisao, setPecaParaRevisao] = useState<Peca | null>(null);
const [showPainelRevisoes, setShowPainelRevisoes] = useState(false);
const handleSave = async (formData: any) => { const handleSave = async (formData: any) => {
if (!canCreate()) return false; if (!canCreate()) return false;
@@ -242,6 +247,8 @@ export default function CadastroPecas() {
onDeletePecasBatch={canDelete() ? handleDeleteBatch : undefined} onDeletePecasBatch={canDelete() ? handleDeleteBatch : undefined}
onEditPeca={canEdit() ? handleEdit : undefined} onEditPeca={canEdit() ? handleEdit : undefined}
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined} onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
onOpenRevisaoPeca={(p) => setPecaParaRevisao(p)}
onOpenPainelRevisoes={() => setShowPainelRevisoes(true)}
hasRecentImport={hasRecentImport} hasRecentImport={hasRecentImport}
canEdit={canEdit()} canEdit={canEdit()}
canDelete={canDelete()} canDelete={canDelete()}
@@ -260,6 +267,29 @@ export default function CadastroPecas() {
peca={selectedPecaForPopup} peca={selectedPecaForPopup}
/> />
)} )}
{/* Modal de Gestão de Revisão da Peça */}
{pecaParaRevisao && (
<RevisaoPecaModal
isOpen={!!pecaParaRevisao}
onClose={() => setPecaParaRevisao(null)}
peca={pecaParaRevisao}
onPecaAtualizada={() => {
loadPecas();
}}
/>
)}
{/* Modal Painel Geral de Revisões */}
{showPainelRevisoes && (
<PainelRevisoesOFModal
isOpen={showPainelRevisoes}
onClose={() => setShowPainelRevisoes(false)}
ofNumber={pecas[0]?.of_number || ''}
pecas={pecas}
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
/>
)}
</div> </div>
</div> </div>
); );
+46 -2
View File
@@ -7,7 +7,10 @@ import { ArrowLeft, Plus, Package } from 'lucide-react';
import { PecaForm } from '@/components/pecas/PecaForm'; import { PecaForm } from '@/components/pecas/PecaForm';
import { PecasTable } from '@/components/pecas/PecasTable'; import { PecasTable } from '@/components/pecas/PecasTable';
import { ComponentesPopup } from '@/components/pecas/ComponentesPopup'; import { ComponentesPopup } from '@/components/pecas/ComponentesPopup';
import { RevisaoPecaModal } from '@/components/pecas/revisoes/RevisaoPecaModal';
import { PainelRevisoesOFModal } from '@/components/pecas/revisoes/PainelRevisoesOFModal';
import { usePecas, Peca } from '@/hooks/usePecas'; import { usePecas, Peca } from '@/hooks/usePecas';
import { useRevisoes } from '@/hooks/useRevisoes';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { usePermissionControl } from '@/hooks/usePermissionControl'; import { usePermissionControl } from '@/hooks/usePermissionControl';
import { StandardPageLayout } from '@/components/layout/StandardPageLayout'; import { StandardPageLayout } from '@/components/layout/StandardPageLayout';
@@ -34,11 +37,15 @@ export default function CadastroPecasFiltrado() {
batchUpdatePecas, batchUpdatePecas,
loadPecas loadPecas
} = usePecas(); } = usePecas();
const { revisoes: revisoesOF, carregarRevisoesOF } = useRevisoes(ofSelecionada);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [editingPeca, setEditingPeca] = useState<Peca | null>(null); const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null); const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
const [pecaParaRevisao, setPecaParaRevisao] = useState<Peca | null>(null);
const [showPainelRevisoes, setShowPainelRevisoes] = useState(false);
// Filtrar peças pela OF selecionada // Filtrar peças pela OF selecionada
const pecasFiltradas = pecas.filter(peca => peca.of_number === ofSelecionada); const pecasFiltradas = pecas.filter(peca => peca.of_number === ofSelecionada);
@@ -170,8 +177,10 @@ export default function CadastroPecasFiltrado() {
useEffect(() => { useEffect(() => {
if (!ofSelecionada) { if (!ofSelecionada) {
navigate('/seletor-of'); navigate('/seletor-of');
} else {
carregarRevisoesOF(ofSelecionada);
} }
}, [ofSelecionada, navigate]); }, [ofSelecionada, navigate, carregarRevisoesOF]);
if (loading) { if (loading) {
return ( return (
@@ -184,6 +193,14 @@ export default function CadastroPecasFiltrado() {
); );
} }
const handleVoltar = () => {
if (window.history.state && window.history.state.idx > 0) {
navigate(-1);
} else {
navigate('/seletor-of');
}
};
if (!ofSelecionada) { if (!ofSelecionada) {
return null; return null;
} }
@@ -197,7 +214,7 @@ export default function CadastroPecasFiltrado() {
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => navigate('/ordens-fabricacao')} onClick={handleVoltar}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -269,6 +286,9 @@ export default function CadastroPecasFiltrado() {
onEditPeca={canEdit() ? handleEdit : undefined} onEditPeca={canEdit() ? handleEdit : undefined}
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined} onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
onBatchUpdatePecas={canEdit() ? handleBatchUpdatePecas : undefined} onBatchUpdatePecas={canEdit() ? handleBatchUpdatePecas : undefined}
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
onOpenPainelRevisoes={() => setShowPainelRevisoes(true)}
totalRevisoesOF={revisoesOF.length}
hasRecentImport={hasRecentImport} hasRecentImport={hasRecentImport}
canEdit={canEdit()} canEdit={canEdit()}
canDelete={canDelete()} canDelete={canDelete()}
@@ -288,6 +308,30 @@ export default function CadastroPecasFiltrado() {
peca={selectedPecaForPopup} peca={selectedPecaForPopup}
/> />
)} )}
{/* Modal de Gestão de Revisão da Peça */}
{pecaParaRevisao && (
<RevisaoPecaModal
isOpen={!!pecaParaRevisao}
onClose={() => setPecaParaRevisao(null)}
peca={pecaParaRevisao}
onPecaAtualizada={() => {
loadPecas();
carregarRevisoesOF(ofSelecionada);
}}
/>
)}
{/* Modal Painel Geral de Revisões da OF */}
{showPainelRevisoes && (
<PainelRevisoesOFModal
isOpen={showPainelRevisoes}
onClose={() => setShowPainelRevisoes(false)}
ofNumber={ofSelecionada}
pecas={pecasFiltradas}
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
/>
)}
</div> </div>
</StandardPageLayout> </StandardPageLayout>
); );
+34 -11
View File
@@ -13,6 +13,8 @@ import { useState, useEffect } from 'react';
import { useDiarioProducao, type RecursoProducao, type ApontamentoDiarioRecurso, type LoteSoldaDiario } from '@/hooks/useDiarioProducao'; import { useDiarioProducao, type RecursoProducao, type ApontamentoDiarioRecurso, type LoteSoldaDiario } from '@/hooks/useDiarioProducao';
import { useUserProfile } from '@/hooks/useUserProfile'; import { useUserProfile } from '@/hooks/useUserProfile';
import { useFichaTecnica } from '@/hooks/useFichaTecnica'; import { useFichaTecnica } from '@/hooks/useFichaTecnica';
import { usePermissions } from '@/hooks/usePermissions';
import { toast } from 'sonner';
interface DiarioData { interface DiarioData {
id?: string; id?: string;
@@ -28,6 +30,7 @@ interface DiarioData {
} }
const DiarioProducao = () => { const DiarioProducao = () => {
const { canRegra } = usePermissions();
const [activeTab, setActiveTab] = useState('diario'); const [activeTab, setActiveTab] = useState('diario');
const [selectedOF, setSelectedOF] = useState(''); const [selectedOF, setSelectedOF] = useState('');
const [showOFModal, setShowOFModal] = useState(false); const [showOFModal, setShowOFModal] = useState(false);
@@ -131,6 +134,12 @@ const DiarioProducao = () => {
const salvarDiarioCompleto = async () => { const salvarDiarioCompleto = async () => {
try { try {
const hoje = new Date().toISOString().split('T')[0];
if (currentDiario.data < hoje && !canRegra('pcp_retroativo_permitido')) {
toast.error('Atenção: Você não possui permissão para apontar produção com data retroativa.');
return;
}
// 1. Salvar o diário principal // 1. Salvar o diário principal
const diarioSalvo = await salvarDiario.mutateAsync({ const diarioSalvo = await salvarDiario.mutateAsync({
data: currentDiario.data, data: currentDiario.data,
@@ -608,17 +617,31 @@ const DiarioProducao = () => {
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="sm"> {canRegra('pcp_exportar_relatorios') && (
<Printer className="h-4 w-4" /> <Button
</Button> variant="ghost"
<Button size="sm"
variant="ghost" title="Exportar/Imprimir Diário"
size="sm" onClick={() => window.print()}
onClick={() => diario.id && deletarDiario.mutate(diario.id)} >
disabled={deletarDiario.isPending} <Printer className="h-4 w-4" />
> </Button>
<Trash2 className="h-4 w-4 text-destructive" /> )}
</Button> {canRegra('pcp_excluir_apontamento') && (
<Button
variant="ghost"
size="sm"
title="Excluir Diário"
onClick={() => {
if (confirm('Tem certeza que deseja excluir este diário de produção?')) {
diario.id && deletarDiario.mutate(diario.id);
}
}}
disabled={deletarDiario.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div> </div>
</td> </td>
</tr> </tr>
+14 -4
View File
@@ -29,8 +29,11 @@ import { useOFsAtivas } from '@/hooks/useOFsAtivas';
import { useMobileResponsive } from '@/hooks/useMobileResponsive'; import { useMobileResponsive } from '@/hooks/useMobileResponsive';
import { RomaneioExpedicao } from '@/hooks/useRomaneios'; import { RomaneioExpedicao } from '@/hooks/useRomaneios';
import { RelatoriosExpedicao } from '@/components/expedicao/RelatoriosExpedicao'; import { RelatoriosExpedicao } from '@/components/expedicao/RelatoriosExpedicao';
import { usePermissions } from '@/hooks/usePermissions';
import { toast } from 'sonner';
const Expedicao = () => { const Expedicao = () => {
const { canRegra } = usePermissions();
const { isMobile } = useMobileResponsive(); const { isMobile } = useMobileResponsive();
const { data: romaneios, isLoading, refetch } = useRomaneios(); const { data: romaneios, isLoading, refetch } = useRomaneios();
const { data: ofsAtivas, isLoading: isLoadingOFs } = useOFsAtivas(); const { data: ofsAtivas, isLoading: isLoadingOFs } = useOFsAtivas();
@@ -104,6 +107,11 @@ const Expedicao = () => {
}; };
const confirmDelete = async () => { const confirmDelete = async () => {
if (!canRegra('exp_cancelar_estornar')) {
toast.error('Atenção: Você não possui permissão para cancelar ou estornar romaneios de expedição.');
return;
}
if (romaneioToDelete) { if (romaneioToDelete) {
try { try {
await removerRomaneioMutation.mutateAsync(romaneioToDelete); await removerRomaneioMutation.mutateAsync(romaneioToDelete);
@@ -321,10 +329,12 @@ const Expedicao = () => {
</Sheet> </Sheet>
)} )}
<Button size={isMobile ? "sm" : "default"} onClick={() => setShowRomaneioForm(true)}> {canRegra('exp_criar_romaneio') && (
<Plus className="w-4 h-4 mr-2" /> <Button size={isMobile ? "sm" : "default"} onClick={() => setShowRomaneioForm(true)}>
Novo Romaneio <Plus className="w-4 h-4 mr-2" />
</Button> Novo Romaneio
</Button>
)}
</div> </div>
</div> </div>
+1 -46
View File
@@ -6,7 +6,6 @@ import { Users, UserPlus, Shield, Settings, Mail, Activity, LayoutGrid } from 'l
import { UsersTable } from '@/components/users/UsersTable'; import { UsersTable } from '@/components/users/UsersTable';
import { PendingUsersTable } from '@/components/users/PendingUsersTable'; import { PendingUsersTable } from '@/components/users/PendingUsersTable';
import { FunctionsManager } from '@/components/users/FunctionsManager'; import { FunctionsManager } from '@/components/users/FunctionsManager';
import { PrivilegesManager } from '@/components/users/PrivilegesManager';
import { PasswordResetRequests } from '@/components/users/PasswordResetRequests'; import { PasswordResetRequests } from '@/components/users/PasswordResetRequests';
import { SessionLogsSimple } from '@/components/users/SessionLogsSimple'; import { SessionLogsSimple } from '@/components/users/SessionLogsSimple';
import { UserModal } from '@/components/users/UserModal'; import { UserModal } from '@/components/users/UserModal';
@@ -40,9 +39,6 @@ const UserManagement = () => {
createFunction, createFunction,
updateFunction, updateFunction,
deleteFunction, deleteFunction,
createPrivilege: createPrivilegeBase,
updatePrivilege: updatePrivilegeBase,
deletePrivilege,
updateUser, updateUser,
toggleUserStatus, toggleUserStatus,
deleteUser, deleteUser,
@@ -67,15 +63,6 @@ const UserManagement = () => {
); );
} }
// Wrapper functions to match expected signatures
const createPrivilege = async (data: { name: string; description?: string; permissions: Record<string, boolean> }): Promise<void> => {
await createPrivilegeBase(data);
};
const updatePrivilege = async (id: string, data: { name: string; description?: string; permissions: Record<string, boolean> }): Promise<void> => {
await updatePrivilegeBase(id, data);
};
const handleEditUser = (user: UserProfile) => { const handleEditUser = (user: UserProfile) => {
if (!canEdit()) return; if (!canEdit()) return;
setSelectedUser(user); setSelectedUser(user);
@@ -158,7 +145,7 @@ const UserManagement = () => {
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-7 bg-muted h-auto p-1"> <TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-6 bg-muted h-auto p-1">
<TabsTrigger <TabsTrigger
value="matriz" value="matriz"
className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3 font-semibold text-primary data-[state=active]:bg-primary data-[state=active]:text-primary-foreground" className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3 font-semibold text-primary data-[state=active]:bg-primary data-[state=active]:text-primary-foreground"
@@ -210,17 +197,6 @@ const UserManagement = () => {
</TabsTrigger> </TabsTrigger>
)} )}
{canEdit() && (
<TabsTrigger
value="privileges"
className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3"
>
<Shield className="h-3 w-3 md:h-4 md:w-4" />
<span className="hidden sm:inline">Privilégios</span>
<span className="sm:hidden">Priv</span>
</TabsTrigger>
)}
{canView() && ( {canView() && (
<TabsTrigger <TabsTrigger
value="session-logs" value="session-logs"
@@ -311,27 +287,6 @@ const UserManagement = () => {
</TabsContent> </TabsContent>
)} )}
{canEdit() && (
<TabsContent value="privileges" className="space-y-4 mt-4">
<Card className="card-mobile">
<CardHeader className="card-header-mobile">
<CardTitle className="text-lg md:text-xl flex items-center gap-2">
<Shield className="h-4 w-4 md:h-5 md:w-5" />
Gerenciar Privilégios
</CardTitle>
</CardHeader>
<CardContent className="card-content-mobile">
<PrivilegesManager
privileges={privileges as any}
onCreate={canCreate() ? createPrivilege : undefined}
onUpdate={canEdit() ? updatePrivilege : undefined}
onDelete={canDelete() ? deletePrivilege : undefined}
/>
</CardContent>
</Card>
</TabsContent>
)}
{canView() && ( {canView() && (
<TabsContent value="session-logs" className="space-y-4 mt-4"> <TabsContent value="session-logs" className="space-y-4 mt-4">
<SessionLogsSimple users={users} /> <SessionLogsSimple users={users} />
+9 -3
View File
@@ -110,11 +110,11 @@ export default function Visualizador3D() {
.select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material') .select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material')
.eq('of_number', selectedOF); .eq('of_number', selectedOF);
// Fetch apontamentos com join de peca e processo // Fetch apontamentos com join de peca e processo (apenas peças mestres)
const { data: apontamentosData, error: errorApontamentos } = await supabase const { data: apontamentosData, error: errorApontamentos } = await supabase
.from('apontamentos_producao' as any) .from('apontamentos_producao' as any)
.select(` .select(`
id, of_number, peca_id, quantidade_produzida, id, of_number, peca_id, quantidade_produzida, tipo_apontamento, componente_id,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase), peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem) processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`) `)
@@ -148,6 +148,8 @@ export default function Visualizador3D() {
marca: marcaStr, marca: marcaStr,
fase, fase,
ofNumber: selectedOF, ofNumber: selectedOF,
descricao: p.descricao || '',
perfilPrincipal: p.perfil_principal || p.descricao || '',
totalQtd: Number(p.quantidade || 1), totalQtd: Number(p.quantidade || 1),
pesoTotal: Number(p.peso_total || 0), pesoTotal: Number(p.peso_total || 0),
pesoUnitario: Number(p.peso_unitario || 0), pesoUnitario: Number(p.peso_unitario || 0),
@@ -186,6 +188,10 @@ export default function Visualizador3D() {
apontamentosData.sort((a: any, b: any) => (a.processo?.ordem || 0) - (b.processo?.ordem || 0)); apontamentosData.sort((a: any, b: any) => (a.processo?.ordem || 0) - (b.processo?.ordem || 0));
apontamentosData.forEach((ap: any) => { apontamentosData.forEach((ap: any) => {
// Ignora apontamentos de componentes para não inflar as peças mestres
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
if (ap.componente_id) return;
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : ''; const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
const fase = ap.peca?.etapa_fase ? String(ap.peca.etapa_fase).trim() : ''; const fase = ap.peca?.etapa_fase ? String(ap.peca.etapa_fase).trim() : '';
if (marca) { if (marca) {
@@ -695,7 +701,7 @@ export default function Visualizador3D() {
</div> </div>
{/* Main 3D Viewport */} {/* Main 3D Viewport */}
<div className="relative flex-1 min-h-[480px] w-full bg-slate-100 dark:bg-slate-950 rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-xl dark:shadow-2xl"> <div className="relative flex-1 min-h-[560px] w-full bg-slate-100 dark:bg-slate-950 rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-xl dark:shadow-2xl">
{isLoadingModel && ( {isLoadingModel && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center p-6 bg-white dark:bg-slate-950/90 backdrop-blur-md text-slate-900 dark:text-white space-y-4"> <div className="absolute inset-0 z-30 flex flex-col items-center justify-center p-6 bg-white dark:bg-slate-950/90 backdrop-blur-md text-slate-900 dark:text-white space-y-4">
<div className="p-4 bg-cyan-100 text-cyan-600 dark:bg-cyan-500/20 dark:text-cyan-400 rounded-2xl border border-cyan-300 dark:border-cyan-500/40 animate-pulse"> <div className="p-4 bg-cyan-100 text-cyan-600 dark:bg-cyan-500/20 dark:text-cyan-400 rounded-2xl border border-cyan-300 dark:border-cyan-500/40 animate-pulse">
@@ -0,0 +1,442 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Scissors,
CheckCircle2,
AlertTriangle,
Zap,
Plus,
RefreshCw,
Layers,
Wrench,
Flame
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { smartAudio } from '@/utils/smartAudio';
import { toast } from 'sonner';
import { PecaKitInfo, ComponenteKitInfo } from '@/hooks/useKitsProducao';
import { sincronizarPecaKitNoCorte } from '@/utils/sincronizacaoKitsCorte';
interface SmartComponentesCorteModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pecaKit: PecaKitInfo | null;
ofNumber: string;
processoCorteId: string;
onApontamentoSalvo: () => void;
}
export const SmartComponentesCorteModal: React.FC<SmartComponentesCorteModalProps> = ({
open,
onOpenChange,
pecaKit,
ofNumber,
processoCorteId,
onApontamentoSalvo,
}) => {
const { user } = useAuth();
const [salvando, setSalvando] = useState<boolean>(false);
const [quantidadesManual, setQuantidadesManual] = useState<Record<string, number>>({});
if (!pecaKit) return null;
const hoje = new Date().toISOString().split('T')[0];
const nomeUsuario = user?.user_metadata?.nome || user?.email?.split('@')[0] || 'Operador Corte';
// Atualizar valor digitado para um componente específico
const setQtdComp = (compId: string, valor: number) => {
setQuantidadesManual((prev) => ({
...prev,
[compId]: Math.max(1, valor),
}));
};
// ─────────────────────────────────────────────────────────────
// AÇÃO 1: CORTAR 1 KIT COMPLETO (ATALHO RÁPIDO)
// ─────────────────────────────────────────────────────────────
const handleCortarKitCompleto = async (multiplicadorKits: number = 1) => {
try {
setSalvando(true);
smartAudio.playClick();
if (!pecaKit.componentes || pecaKit.componentes.length === 0) {
toast.error('Esta peça não possui subcomponentes cadastrados.');
return;
}
// Preparar lote de inserções para cada componente da peça
const batchPayloads: Array<{
of_number: string;
peca_id: string;
componente_id: string;
tipo_apontamento: 'componente';
processo_id: string;
quantidade_produzida: number;
data_apontamento: string;
created_by?: string;
usuario_nome: string;
observacoes: string;
}> = [];
let pesoTotalKg = 0;
for (const comp of pecaKit.componentes) {
const porKit = comp.quantidade_por_peca || 1;
const qtdParaApontar = porKit * multiplicadorKits;
const restante = Math.max(0, comp.quantidade_total_necessaria - comp.quantidade_cortada);
// Não apontar além do total se já estiver concluído, exceto se solicitado
const qtdFinal = Math.min(qtdParaApontar, Math.max(qtdParaApontar, restante));
if (qtdFinal > 0) {
batchPayloads.push({
of_number: ofNumber,
peca_id: pecaKit.peca_id,
componente_id: comp.id,
tipo_apontamento: 'componente',
processo_id: processoCorteId,
quantidade_produzida: qtdFinal,
data_apontamento: hoje,
created_by: user?.id,
usuario_nome: nomeUsuario,
observacoes: `Corte de Kit Completo (${multiplicadorKits}x ${pecaKit.marca}) via Modo Smart`,
});
pesoTotalKg += qtdFinal * (comp.peso_unitario || 0);
}
}
if (batchPayloads.length === 0) {
toast.info('Todos os componentes desta peça já estão 100% cortados!');
return;
}
const { error } = await supabase.from('apontamentos_producao').insert(batchPayloads);
if (error) throw error;
// Sincronizar liberação de kits completos da peça mestre no Corte para a Solda
const resSync = await sincronizarPecaKitNoCorte({
ofNumber,
pecaId: pecaKit.peca_id,
processoCorteId,
userId: user?.id,
usuarioNome: nomeUsuario,
dataApontamento: hoje,
});
smartAudio.playSuccess();
const infoLiberado =
resSync && resSync.delta_liberado > 0
? ` (+${resSync.delta_liberado} peça(s) liberada(s) para Solda)`
: '';
toast.success(
`✅ ${multiplicadorKits} Kit Completo da peça ${pecaKit.marca} cortado com sucesso! (+${pesoTotalKg.toFixed(1)} kg)${infoLiberado}`
);
onApontamentoSalvo();
onOpenChange(false);
} catch (err: any) {
smartAudio.playAlert();
console.error('Erro ao cortar kit completo:', err);
toast.error('Erro ao registrar kit completo: ' + (err?.message || 'Falha de rede'));
} finally {
setSalvando(false);
}
};
// ─────────────────────────────────────────────────────────────
// AÇÃO 2: APONTAR COMPONENTE INDIVIDUAL (DESBALANCEADO / NESTING)
// ─────────────────────────────────────────────────────────────
const handleApontarComponenteIndividual = async (comp: ComponenteKitInfo) => {
try {
const qtd = quantidadesManual[comp.id] || 1;
if (qtd <= 0) return;
setSalvando(true);
smartAudio.playClick();
const payload = {
of_number: ofNumber,
peca_id: pecaKit.peca_id,
componente_id: comp.id,
tipo_apontamento: 'componente' as const,
processo_id: processoCorteId,
quantidade_produzida: qtd,
data_apontamento: hoje,
created_by: user?.id,
usuario_nome: nomeUsuario,
observacoes: `Corte de subcomponente ${comp.marca_componente} (${qtd} un) via Modo Smart`,
};
const { error } = await supabase.from('apontamentos_producao').insert(payload);
if (error) throw error;
// Sincronizar se este corte completou novos kits completos (respeitando estritamente o gargalo de todos os componentes)
const resSync = await sincronizarPecaKitNoCorte({
ofNumber,
pecaId: pecaKit.peca_id,
processoCorteId,
userId: user?.id,
usuarioNome: nomeUsuario,
dataApontamento: hoje,
});
const pesoKg = qtd * (comp.peso_unitario || 0);
smartAudio.playSuccess();
if (resSync && resSync.delta_liberado > 0) {
toast.success(
`✂️ Apontado no Corte: ${qtd}x ${comp.marca_componente} (+${pesoKg.toFixed(1)} kg) • ⚡ Kit completo formado: +${resSync.delta_liberado} un de ${pecaKit.marca} liberada(s) para Solda!`
);
} else {
toast.success(
`✂️ Apontado no Corte: ${qtd}x ${comp.marca_componente} (+${pesoKg.toFixed(1)} kg)`
);
}
// Limpar valor digitado
setQuantidadesManual((prev) => {
const next = { ...prev };
delete next[comp.id];
return next;
});
onApontamentoSalvo();
} catch (err: any) {
smartAudio.playAlert();
console.error('Erro ao apontar componente:', err);
toast.error('Erro ao apontar componente: ' + (err?.message || 'Falha de rede'));
} finally {
setSalvando(false);
}
};
const kitsProntos = pecaKit.kits_completos_cortados;
const totalKits = pecaKit.quantidade_total;
const pct = pecaKit.percentual_termico;
const gargalo = pecaKit.componente_gargalo;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader className="pb-2 border-b border-slate-200 dark:border-slate-800">
<DialogTitle className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-orange-100 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400">
<Scissors className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-xl font-black text-amber-700 dark:text-amber-400 tracking-wider">
{pecaKit.marca}
</span>
<Badge variant="outline" className="text-xs font-bold">
{pecaKit.etapa_fase.toLowerCase().startsWith('fase')
? pecaKit.etapa_fase
: `Fase ${pecaKit.etapa_fase}`}
</Badge>
{pecaKit.perfil_principal && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 font-mono font-bold">
{pecaKit.perfil_principal}
</span>
)}
</div>
<div className="text-xs text-slate-500">
{pecaKit.descricao || 'Peça Estrutural'} • Total: <strong>{totalKits} un</strong>
</div>
</div>
</div>
<Badge
variant="secondary"
className={`text-xs font-black ${
kitsProntos >= totalKits
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
: kitsProntos > 0
? 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
}`}
>
<Flame className="w-3.5 h-3.5 mr-1 animate-pulse" />
{kitsProntos}/{totalKits} Kits Montáveis ({pct}%)
</Badge>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* ──────────────── Barra de Prontidão e Gargalo ──────────────── */}
<div className="p-3 rounded-2xl bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 space-y-2">
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300">
<span>Prontidão Térmica do Kit de Montagem</span>
<span>{pct}% dos componentes cortados</span>
</div>
<div className="w-full bg-slate-200 dark:bg-slate-800 h-3 rounded-full overflow-hidden flex">
<div
className={`h-full transition-all duration-300 ${
kitsProntos >= totalKits
? 'bg-emerald-500'
: kitsProntos > 0
? 'bg-blue-500'
: 'bg-amber-500'
}`}
style={{ width: `${pct}%` }}
/>
</div>
{gargalo && kitsProntos < totalKits && (
<div className="p-2 rounded-xl bg-amber-100/70 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 flex items-center gap-2 text-xs text-amber-900 dark:text-amber-300 font-semibold">
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0 animate-bounce" />
<span>
<strong>Gargalo do Próximo Kit:</strong> Falta cortar{' '}
<span className="underline decoration-amber-500 font-black">
{gargalo.falta_para_prox_kit} un do componente {gargalo.marca_componente}
</span>{' '}
para liberar mais 1 kit completo na Solda!
</span>
</div>
)}
</div>
{/* ──────────────── ATALHO MESTRE: CORTAR 1 KIT COMPLETO ──────────────── */}
<div className="p-3.5 rounded-2xl bg-gradient-to-r from-amber-500/10 via-amber-500/20 to-orange-500/10 border-2 border-amber-500/40 dark:border-amber-500/30 flex flex-col sm:flex-row items-center justify-between gap-3">
<div>
<div className="flex items-center gap-1.5 text-xs font-black uppercase text-amber-800 dark:text-amber-400">
<Zap className="w-4 h-4 text-amber-600 dark:text-amber-400 fill-current" />
<span>Atalho Rápido de Corte por Conjunto</span>
</div>
<p className="text-xs text-slate-600 dark:text-slate-300 font-medium">
Corta todos os componentes necessários para fechar mais 1 kit montável de uma só vez.
</p>
</div>
<Button
type="button"
disabled={salvando || kitsProntos >= totalKits}
onClick={() => handleCortarKitCompleto(1)}
className="w-full sm:w-auto h-11 px-4 bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 font-black text-xs uppercase tracking-wider rounded-xl shadow-md active:scale-95 transition-all flex items-center justify-center gap-1.5 shrink-0"
>
<Zap className="w-4 h-4 fill-current" />
<span>CORTAR 1 KIT COMPLETO (+1 UN)</span>
</Button>
</div>
{/* ──────────────── LISTA DE SUBCOMPONENTES PARA CORTE AVULSO ──────────────── */}
<div>
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300 mb-2">
<span className="flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-blue-500" />
Corte Individual de Subcomponentes (Nesting / Chapas avulsas):
</span>
<span className="text-slate-400">{pecaKit.componentes.length} subcomponentes</span>
</div>
<div className="space-y-2.5">
{pecaKit.componentes.map((comp) => {
const isGargalo = gargalo?.id === comp.id && kitsProntos < totalKits;
const comp100 = comp.concluido;
const valorAtual = quantidadesManual[comp.id] || comp.quantidade_por_peca || 1;
return (
<div
key={comp.id}
className={`p-3 rounded-2xl border transition-all ${
isGargalo
? 'bg-amber-50/70 dark:bg-amber-950/30 border-amber-400 dark:border-amber-700 shadow-sm'
: comp100
? 'bg-emerald-50/40 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-800'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800'
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5">
{/* Info do componente */}
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-black text-base text-slate-900 dark:text-slate-100">
{comp.marca_componente}
</span>
{comp.perfil && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 font-mono text-slate-700 dark:text-slate-300 font-bold">
{comp.perfil}
</span>
)}
{isGargalo && (
<span className="text-[10px] font-black px-2 py-0.5 rounded bg-amber-500 text-slate-950 uppercase tracking-tight">
Gargalo do Kit
</span>
)}
{comp100 && (
<span className="text-[10px] font-black px-2 py-0.5 rounded bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
✅ 100% Cortado
</span>
)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{comp.quantidade_por_peca} un/peça • Total da OF:{' '}
<strong>{comp.quantidade_total_necessaria} un</strong> • Já cortado:{' '}
<strong className="text-slate-900 dark:text-slate-100 font-mono">
{comp.quantidade_cortada} un
</strong>{' '}
({comp.percentual_cortado}%) • {comp.peso_unitario > 0 ? `${comp.peso_unitario.toFixed(2)} kg/un` : ''}
</div>
</div>
{/* Teclado de corte rápido deste componente */}
<div className="flex items-center gap-1.5 shrink-0 self-end sm:self-center">
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl border border-slate-200 dark:border-slate-700">
<button
type="button"
onClick={() => setQtdComp(comp.id, comp.quantidade_por_peca)}
className="px-2 py-1 rounded-lg text-xs font-black bg-white dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 shadow-xs"
title="1 kit da peça"
>
+{comp.quantidade_por_peca}
</button>
<button
type="button"
onClick={() => setQtdComp(comp.id, comp.quantidade_por_peca * 2)}
className="px-2 py-1 rounded-lg text-xs font-black bg-white dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 shadow-xs"
title="2 kits da peça"
>
+{comp.quantidade_por_peca * 2}
</button>
<Input
type="number"
min="1"
value={valorAtual}
onChange={(e) => setQtdComp(comp.id, parseInt(e.target.value) || 1)}
className="w-14 h-7 text-xs text-center font-bold px-1"
/>
</div>
<Button
type="button"
size="sm"
disabled={salvando}
onClick={() => handleApontarComponenteIndividual(comp)}
className="h-8 px-3 bg-slate-900 hover:bg-slate-800 text-white dark:bg-amber-500 dark:hover:bg-amber-400 dark:text-slate-950 font-black text-xs uppercase rounded-xl active:scale-95"
>
Apontar ({valorAtual})
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -90,10 +90,10 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
.from('processos_fabricacao') .from('processos_fabricacao')
.select('id, nome, ordem'), .select('id, nome, ordem'),
// 3. Apontamentos da OF // 3. Apontamentos da OF (apenas peças mestres)
supabase supabase
.from('apontamentos_producao') .from('apontamentos_producao')
.select('peca_id, processo_id, quantidade_produzida') .select('peca_id, processo_id, quantidade_produzida, tipo_apontamento, componente_id')
.eq('of_number', obra.of_number), .eq('of_number', obra.of_number),
// 4. Expedição em Romaneios (APENAS ROMANEIOS ENTREGUES E MAPEANDO POR PECA_ID PARA EVITAR CONFLITO DE FASE) // 4. Expedição em Romaneios (APENAS ROMANEIOS ENTREGUES E MAPEANDO POR PECA_ID PARA EVITAR CONFLITO DE FASE)
@@ -132,6 +132,10 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
>(); >();
(resApontamentos.data || []).forEach((ap: any) => { (resApontamentos.data || []).forEach((ap: any) => {
// Ignora apontamentos de componentes para não inflar as quantidades da peça mestre
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
if (ap.componente_id) return;
const pecaId = ap.peca_id; const pecaId = ap.peca_id;
const nomeProc = mapaNomeProcesso.get(ap.processo_id) || ''; const nomeProc = mapaNomeProcesso.get(ap.processo_id) || '';
const qtd = Number(ap.quantidade_produzida || 0); const qtd = Number(ap.quantidade_produzida || 0);
+323 -47
View File
@@ -15,7 +15,8 @@ import {
Filter, Filter,
Delete, Delete,
Pencil, Pencil,
CheckCheck CheckCheck,
Flame
} from 'lucide-react'; } from 'lucide-react';
import { OFAtiva } from '@/hooks/useOFsAtivas'; import { OFAtiva } from '@/hooks/useOFsAtivas';
import { useProcessosFabricacao, ProcessoFabricacao } from '@/hooks/useProcessosFabricacao'; import { useProcessosFabricacao, ProcessoFabricacao } from '@/hooks/useProcessosFabricacao';
@@ -25,6 +26,10 @@ import { smartAudio } from '@/utils/smartAudio';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { SmartForcarApontamentoModal } from './SmartForcarApontamentoModal'; import { SmartForcarApontamentoModal } from './SmartForcarApontamentoModal';
import { calcularKitsOF, PecaKitInfo } from '@/hooks/useKitsProducao';
import { MapaTermicoKitsModal } from '@/components/apontamento/kits/MapaTermicoKitsModal';
import { SmartComponentesCorteModal } from './SmartComponentesCorteModal';
import { sincronizarTodosKitsOFCorte } from '@/utils/sincronizacaoKitsCorte';
interface SmartProducaoFlowProps { interface SmartProducaoFlowProps {
obra: OFAtiva; obra: OFAtiva;
@@ -58,7 +63,8 @@ export const calcularSaldoProcessoPeca = (
peca: PecaData, peca: PecaData,
processoAtual: ProcessoFabricacao, processoAtual: ProcessoFabricacao,
todosProcessos: ProcessoFabricacao[], todosProcessos: ProcessoFabricacao[],
mapaProd: Map<string, number> mapaProd: Map<string, number>,
mapaKits?: Map<string, PecaKitInfo>
): CalculoSaldoPeca => { ): CalculoSaldoPeca => {
const ehSemMontagem = peca.tem_componentes === false; const ehSemMontagem = peca.tem_componentes === false;
const keyAtual = `${peca.id}_${processoAtual.id}`; const keyAtual = `${peca.id}_${processoAtual.id}`;
@@ -68,15 +74,27 @@ export const calcularSaldoProcessoPeca = (
const indexAtual = procsOrdenados.findIndex((p) => p.id === processoAtual.id); const indexAtual = procsOrdenados.findIndex((p) => p.id === processoAtual.id);
const nomeProcAtual = (processoAtual.nome || '').toLowerCase(); const nomeProcAtual = (processoAtual.nome || '').toLowerCase();
const ehSolda = nomeProcAtual.includes('solda'); const ehSolda = nomeProcAtual.includes('solda') || nomeProcAtual.includes('montag');
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0; let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
let motivoBloqueio: string | null = null; let motivoBloqueio: string | null = null;
const kitInfo = mapaKits?.get(peca.id);
const temControleComponentes = Boolean(kitInfo && kitInfo.tem_componentes && (kitInfo.componentes?.length || 0) > 0);
// REGRA 1: Peças sem montagem (S/M) não passam por Solda // REGRA 1: Peças sem montagem (S/M) não passam por Solda
if (ehSemMontagem && ehSolda) { if (ehSemMontagem && ehSolda) {
qtdDisponivelParaEntrar = 0; qtdDisponivelParaEntrar = 0;
motivoBloqueio = 'Peça S/M (Pula Solda - vai direto para Pintura)'; motivoBloqueio = 'Peça S/M (Pula Solda - vai direto para Pintura)';
} else if (ehSolda && !ehSemMontagem && temControleComponentes && kitInfo) {
// REGRA 1.1: REGRA DO KIT COMPLETO DE CORTE PARA A SOLDA!
// Apenas peças com componentes 100% cortados (em lotes de kits inteiros) são liberadas para Solda!
qtdDisponivelParaEntrar = kitInfo.kits_completos_cortados;
if (kitInfo.kits_completos_cortados === 0) {
motivoBloqueio = kitInfo.motivo_bloqueio_solda || 'Aguardando corte completo dos kits';
} else if (kitInfo.kits_completos_cortados <= jaProduzidoAtual) {
motivoBloqueio = `Todos os ${kitInfo.kits_completos_cortados} kits cortados já foram soldados (${jaProduzidoAtual}/${peca.quantidade})`;
}
} else if (indexAtual > 0) { } else if (indexAtual > 0) {
// REGRA 2: Encontrar processo anterior válido // REGRA 2: Encontrar processo anterior válido
let procAnteriorValido: ProcessoFabricacao | null = null; let procAnteriorValido: ProcessoFabricacao | null = null;
@@ -94,7 +112,8 @@ export const calcularSaldoProcessoPeca = (
if (procAnteriorValido) { if (procAnteriorValido) {
const keyAnt = `${peca.id}_${procAnteriorValido.id}`; const keyAnt = `${peca.id}_${procAnteriorValido.id}`;
const qtdApontadaAnterior = mapaProd.get(keyAnt) || 0; const qtdApontadaAnterior = mapaProd.get(keyAnt) || 0;
qtdDisponivelParaEntrar = qtdApontadaAnterior; // Trava de teto: o saldo para entrar jamais pode exceder o total da peça
qtdDisponivelParaEntrar = Math.min(Number(peca.quantidade) || 0, qtdApontadaAnterior);
if (qtdApontadaAnterior === 0) { if (qtdApontadaAnterior === 0) {
motivoBloqueio = `Aguardando ${procAnteriorValido.nome}`; motivoBloqueio = `Aguardando ${procAnteriorValido.nome}`;
@@ -127,7 +146,7 @@ export const calcularSaldoProcessoPeca = (
} }
} }
const saldo = Math.max(0, qtdDisponivelParaEntrar - jaProduzidoAtual); const saldo = Math.max(0, Math.min(Number(peca.quantidade) || 0, qtdDisponivelParaEntrar) - jaProduzidoAtual);
return { return {
saldo, saldo,
@@ -149,6 +168,8 @@ interface ApontamentoItem {
status_confirmacao?: string; status_confirmacao?: string;
forcado_por_user_nome?: string; forcado_por_user_nome?: string;
usuario_nome?: string; usuario_nome?: string;
tipo_apontamento?: string;
componente_id?: string | null;
} }
export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
@@ -173,12 +194,17 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
// Modal de Forçar Apontamento // Modal de Forçar Apontamento
const [isForcarModalOpen, setIsForcarModalOpen] = useState<boolean>(false); const [isForcarModalOpen, setIsForcarModalOpen] = useState<boolean>(false);
// Estados do Mapa Térmico de Kits e Corte de Componentes
const [isMapaKitsOpen, setIsMapaKitsOpen] = useState<boolean>(false);
const [pecaParaCorteModal, setPecaParaCorteModal] = useState<PecaKitInfo | null>(null);
const [componentesOF, setComponentesOF] = useState<any[]>([]);
// Dados carregados da OF // Dados carregados da OF
const [pecas, setPecas] = useState<PecaData[]>([]); const [pecas, setPecas] = useState<PecaData[]>([]);
const [apontamentosExistentes, setApontamentosExistentes] = useState<ApontamentoItem[]>([]); const [apontamentosExistentes, setApontamentosExistentes] = useState<ApontamentoItem[]>([]);
const [carregandoDados, setCarregandoDados] = useState<boolean>(true); const [carregandoDados, setCarregandoDados] = useState<boolean>(true);
// Carregar peças e apontamentos da OF // Carregar peças, componentes e apontamentos da OF
const carregarDadosOF = React.useCallback(async () => { const carregarDadosOF = React.useCallback(async () => {
try { try {
setCarregandoDados(true); setCarregandoDados(true);
@@ -190,12 +216,28 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
.eq('of_number', obra.of_number), .eq('of_number', obra.of_number),
supabase supabase
.from('apontamentos_producao') .from('apontamentos_producao')
.select('id, peca_id, processo_id, quantidade_produzida, is_forcado, status_confirmacao, forcado_por_user_nome, usuario_nome') .select('id, peca_id, processo_id, quantidade_produzida, is_forcado, status_confirmacao, forcado_por_user_nome, usuario_nome, tipo_apontamento, componente_id')
.eq('of_number', obra.of_number), .eq('of_number', obra.of_number),
]); ]);
if (resPecas.data) { if (resPecas.data) {
setPecas(resPecas.data); setPecas(resPecas.data);
// Buscar componentes das peças da OF em lotes
const pecaIds = resPecas.data.map((p) => p.id);
if (pecaIds.length > 0) {
let todosComps: any[] = [];
for (let i = 0; i < pecaIds.length; i += 300) {
const chunk = pecaIds.slice(i, i + 300);
const { data: compsData } = await supabase
.from('componentes_peca')
.select('id, peca_id, marca_componente, descricao, perfil, peso_unitario, quantidade_por_peca')
.in('peca_id', chunk);
if (compsData) todosComps = [...todosComps, ...compsData];
}
setComponentesOF(todosComps);
}
} }
if (resApontamentos.data) { if (resApontamentos.data) {
setApontamentosExistentes(resApontamentos.data as any); setApontamentosExistentes(resApontamentos.data as any);
@@ -224,10 +266,14 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
return lista.length > 0 ? lista : ['Geral']; return lista.length > 0 ? lista : ['Geral'];
}, [pecas]); }, [pecas]);
// Mapa de produção acumulada por peca_id e processo_id // Mapa de produção acumulada por peca_id e processo_id (apenas apontamentos de peças mestres)
const mapaProducao = useMemo(() => { const mapaProducao = useMemo(() => {
const mapa = new Map<string, number>(); const mapa = new Map<string, number>();
apontamentosExistentes.forEach((ap) => { apontamentosExistentes.forEach((ap) => {
// Ignora apontamentos de componentes para não inflar a contagem da peça pai
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
if (ap.componente_id) return;
const key = `${ap.peca_id}_${ap.processo_id}`; const key = `${ap.peca_id}_${ap.processo_id}`;
const atual = mapa.get(key) || 0; const atual = mapa.get(key) || 0;
mapa.set(key, atual + Number(ap.quantidade_produzida || 0)); mapa.set(key, atual + Number(ap.quantidade_produzida || 0));
@@ -238,7 +284,11 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
// Apontamentos forçados que ainda estão pendentes de confirmação de baixa // Apontamentos forçados que ainda estão pendentes de confirmação de baixa
const apontamentosPendentes = useMemo(() => { const apontamentosPendentes = useMemo(() => {
return apontamentosExistentes.filter( return apontamentosExistentes.filter(
(ap) => ap.is_forcado && ap.status_confirmacao === 'pendente_confirmacao' (ap) =>
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
!ap.componente_id &&
ap.is_forcado &&
ap.status_confirmacao === 'pendente_confirmacao'
); );
}, [apontamentosExistentes]); }, [apontamentosExistentes]);
@@ -281,6 +331,32 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
return map; return map;
}, [apontamentosPendentes, processoSelecionado]); }, [apontamentosPendentes, processoSelecionado]);
// Identificar processos de Corte e Solda
const procCorteId = useMemo(() => {
return processos.find((p) => {
const n = (p.nome || '').toLowerCase();
return n.includes('corte') || n.includes('prepar') || p.ordem === 1;
})?.id;
}, [processos]);
const procSoldaId = useMemo(() => {
return processos.find((p) => {
const n = (p.nome || '').toLowerCase();
return n.includes('solda') || n.includes('montag') || p.ordem === 3;
})?.id;
}, [processos]);
// Mapa Térmico e de Kits completo para toda a OF
const mapaKits = useMemo(() => {
return calcularKitsOF(
pecas,
componentesOF,
apontamentosExistentes,
procCorteId,
procSoldaId
);
}, [pecas, componentesOF, apontamentosExistentes, procCorteId, procSoldaId]);
// Peças filtradas pela fase selecionada com cálculo estrito de precedência de processos e regra S/M // Peças filtradas pela fase selecionada com cálculo estrito de precedência de processos e regra S/M
const pecasComSaldo = useMemo(() => { const pecasComSaldo = useMemo(() => {
if (!processoSelecionado) return []; if (!processoSelecionado) return [];
@@ -291,7 +367,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
return (p.etapa_fase || 'Geral').trim() === faseSelecionada; return (p.etapa_fase || 'Geral').trim() === faseSelecionada;
}) })
.map((p) => { .map((p) => {
const info = calcularSaldoProcessoPeca(p, processoSelecionado, processos, mapaProducao); const info = calcularSaldoProcessoPeca(p, processoSelecionado, processos, mapaProducao, mapaKits);
return { return {
...p, ...p,
...info, ...info,
@@ -309,7 +385,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
if (a.saldo === 0 && b.saldo > 0) return 1; if (a.saldo === 0 && b.saldo > 0) return 1;
return a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }); return a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' });
}); });
}, [pecas, faseSelecionada, processoSelecionado, mapaProducao, processos, pecasPendentesMap]); }, [pecas, faseSelecionada, processoSelecionado, mapaProducao, processos, pecasPendentesMap, mapaKits]);
// Helper de ícone por processo // Helper de ícone por processo
const getIconeProcesso = (nome: string) => { const getIconeProcesso = (nome: string) => {
@@ -328,6 +404,23 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
smartAudio.playClick(); smartAudio.playClick();
setProcessoSelecionado(proc); setProcessoSelecionado(proc);
setEtapaAtual('fase'); setEtapaAtual('fase');
// Ao selecionar Solda ou Montagem, rodar sincronização preventiva de kits da OF
const nomeP = (proc.nome || '').toLowerCase();
if ((nomeP.includes('solda') || nomeP.includes('montag')) && procCorteId && obra.of_number) {
sincronizarTodosKitsOFCorte({
ofNumber: obra.of_number,
processoCorteId: procCorteId,
userId: user?.id,
usuarioNome: user?.name || user?.username || 'Operador',
})
.then((res) => {
if (res.some((r) => r.delta_liberado > 0)) {
carregarDadosOF();
}
})
.catch((e) => console.warn('Erro na sincronização preventiva de kits:', e));
}
}; };
const handleEscolherFase = (fase: string) => { const handleEscolherFase = (fase: string) => {
@@ -388,7 +481,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
return; return;
} }
const info = calcularSaldoProcessoPeca(pecaSelecionada, processoSelecionado, processos, mapaProducao); const info = calcularSaldoProcessoPeca(pecaSelecionada, processoSelecionado, processos, mapaProducao, mapaKits);
if (modoEdicao && qtdApontar < info.minPermitido) { if (modoEdicao && qtdApontar < info.minPermitido) {
smartAudio.playAlert(); smartAudio.playAlert();
toast.error(`Não é possível reduzir para ${qtdApontar}. O processo seguinte (${info.procSeguinteNome}) já consumiu ${info.minPermitido} peças. Ajuste o processo seguinte primeiro.`); toast.error(`Não é possível reduzir para ${qtdApontar}. O processo seguinte (${info.procSeguinteNome}) já consumiu ${info.minPermitido} peças. Ajuste o processo seguinte primeiro.`);
@@ -749,6 +842,19 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
> >
<span>🏢 {faseSelecionada}</span> <span>🏢 {faseSelecionada}</span>
</button> </button>
<button
type="button"
onClick={() => {
smartAudio.playClick();
setIsMapaKitsOpen(true);
}}
className="px-2.5 py-1 rounded-lg bg-orange-100 text-orange-800 dark:bg-orange-500/20 dark:text-orange-300 font-bold text-xs flex items-center gap-1 border border-orange-200 dark:border-orange-500/30 hover:bg-orange-200 dark:hover:bg-orange-500/30 shrink-0 transition-colors"
title="Abrir Mapa Térmico de Kits de Montagem"
>
<Flame className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400 fill-current animate-pulse" />
<span>Mapa de Kits</span>
</button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -794,6 +900,10 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
const temPendencia = pendenciasItem.length > 0; const temPendencia = pendenciasItem.length > 0;
const concluida = item.jaProduzido >= item.quantidade; const concluida = item.jaProduzido >= item.quantidade;
const aguardandoAnterior = !concluida && item.saldo <= 0; const aguardandoAnterior = !concluida && item.saldo <= 0;
const kitInfo = mapaKits.get(item.id);
const ehCorte = processoSelecionado?.id === procCorteId;
const ehSolda = processoSelecionado?.id === procSoldaId;
const temComponentes = item.tem_componentes !== false && (kitInfo?.componentes.length || 0) > 0;
return ( return (
<div <div
@@ -875,7 +985,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-slate-600 dark:text-slate-400 px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 mt-1" title={item.motivoBloqueio || 'Aguardando processo anterior'}> <span className="inline-flex items-center gap-1 text-[11px] font-bold text-slate-600 dark:text-slate-400 px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 mt-1" title={item.motivoBloqueio || 'Aguardando processo anterior'}>
{item.motivoBloqueio ? item.motivoBloqueio.slice(0, 22) : 'Aguardando anterior'} {item.motivoBloqueio ? item.motivoBloqueio.slice(0, 26) : 'Aguardando anterior'}
</span> </span>
)} )}
</div> </div>
@@ -893,6 +1003,68 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
/> />
</div> </div>
{/* MAPA TÉRMICO DE KITS NO CARD (QUANDO PEÇA POSSUI COMPONENTES) */}
{kitInfo && temComponentes && (
<div className="mt-2.5 p-2.5 rounded-xl bg-orange-50/70 dark:bg-orange-950/20 border border-orange-200/80 dark:border-orange-800/40 flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 font-bold">
<Flame
className={`w-3.5 h-3.5 shrink-0 ${
kitInfo.kits_completos_cortados > 0
? 'text-orange-500 fill-orange-500 animate-pulse'
: 'text-slate-400'
}`}
/>
<span className="text-slate-800 dark:text-slate-200 font-extrabold text-[11px] uppercase tracking-wide">
Kits Montáveis no Corte:
</span>
<span
className={`px-1.5 py-0.5 rounded text-[11px] font-black ${
kitInfo.kits_completos_cortados >= item.quantidade
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
: kitInfo.kits_completos_cortados > 0
? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
: 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-400'
}`}
>
{kitInfo.kits_completos_cortados} de {item.quantidade} un
</span>
</div>
<span className="text-[11px] font-black text-orange-700 dark:text-orange-400">
{kitInfo.percentual_termico}% peso
</span>
</div>
{/* Mini barra térmica do kit com gradiente */}
<div className="w-full bg-slate-200 dark:bg-slate-800 h-1.5 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
kitInfo.percentual_termico >= 100
? 'bg-emerald-500'
: kitInfo.percentual_termico >= 50
? 'bg-amber-500'
: kitInfo.percentual_termico > 0
? 'bg-orange-500'
: 'bg-slate-300 dark:bg-slate-700'
}`}
style={{ width: `${kitInfo.percentual_termico}%` }}
/>
</div>
{/* Gargalo se houver */}
{kitInfo.componente_gargalo && kitInfo.kits_completos_cortados < item.quantidade && (
<div className="text-[10px] text-amber-800 dark:text-amber-300 font-medium flex items-center justify-between">
<span className="truncate">
⚠️ Gargalo: <strong>{kitInfo.componente_gargalo.marca_componente || kitInfo.componente_gargalo.descricao}</strong> (falta {kitInfo.componente_gargalo.falta_para_prox_kit} un p/ próx kit)
</span>
<span className="text-[10px] text-slate-400 shrink-0 ml-1">
{kitInfo.componentes.length} itens
</span>
</div>
)}
</div>
)}
{/* ALERTA VISUAL: BAIXA FORÇADA PENDENTE DE CONFIRMAÇÃO */} {/* ALERTA VISUAL: BAIXA FORÇADA PENDENTE DE CONFIRMAÇÃO */}
{temPendencia && ( {temPendencia && (
<div className="mt-3 p-2.5 rounded-xl bg-red-100 dark:bg-red-950/60 border border-red-300 dark:border-red-800 flex items-center justify-between gap-2 flex-wrap"> <div className="mt-3 p-2.5 rounded-xl bg-red-100 dark:bg-red-950/60 border border-red-300 dark:border-red-800 flex items-center justify-between gap-2 flex-wrap">
@@ -916,40 +1088,107 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
{/* Botões de Ação no Card */} {/* Botões de Ação no Card */}
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
<Button {ehCorte && temComponentes && kitInfo ? (
type="button" <>
disabled={concluida || item.saldo <= 0} <Button
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)} type="button"
className={`sm:col-span-2 h-13 text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${ onClick={() => {
concluida smartAudio.playClick();
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed' setPecaParaCorteModal(kitInfo);
: item.saldo <= 0 }}
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed' className="sm:col-span-2 h-13 text-xs sm:text-sm font-black rounded-xl uppercase tracking-wider bg-orange-600 hover:bg-orange-500 active:bg-orange-700 text-white shadow-md shadow-orange-950/20 active:scale-95 transition-all flex items-center justify-center gap-2"
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30' title="Apontar componentes individuais cortados ou cortar 1 kit completo"
}`} >
> <Scissors className="w-4 h-4 shrink-0" />
{concluida <span>Cortar Componentes ({kitInfo.componentes.length} itens)</span>
? 'Processo Concluído' </Button>
: item.saldo <= 0
? item.motivoBloqueio || 'Aguardando processo anterior'
: `DIGITAR / ESCOLHER QUANTIDADE`}
</Button>
{!concluida && item.saldo > 0 && ( <Button
<Button type="button"
type="button" disabled={concluida || item.saldo <= 0}
onClick={() => { onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
smartAudio.playClick(); className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors disabled:opacity-50"
setPecaSelecionada(item); title="Apontar peça mestre inteira"
setQtdApontar(item.saldo); >
setModoEdicao(false); Peça Cheia ({item.saldo})
setEtapaAtual('quantidade'); </Button>
}} </>
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors" ) : ehSolda && temComponentes && kitInfo ? (
title="Apontar todo o saldo disponível de uma vez" <>
> <Button
TODAS ({item.saldo}) type="button"
</Button> disabled={concluida || item.saldo <= 0}
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
className={`sm:col-span-2 h-13 text-xs sm:text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
concluida
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
: item.saldo <= 0
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
}`}
>
{concluida
? 'Processo Concluído'
: item.saldo <= 0
? (item.motivoBloqueio || 'Aguardando kits completos no Corte')
: `⚡ SOLDAR (${item.saldo} KITS LIBERADOS)`}
</Button>
{!concluida && item.saldo > 0 && (
<Button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaSelecionada(item);
setQtdApontar(item.saldo);
setModoEdicao(false);
setEtapaAtual('quantidade');
}}
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
title="Apontar todos os kits disponíveis de uma vez"
>
TODAS ({item.saldo})
</Button>
)}
</>
) : (
<>
<Button
type="button"
disabled={concluida || item.saldo <= 0}
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
className={`sm:col-span-2 h-13 text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
concluida
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
: item.saldo <= 0
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
}`}
>
{concluida
? 'Processo Concluído'
: item.saldo <= 0
? item.motivoBloqueio || 'Aguardando processo anterior'
: `DIGITAR / ESCOLHER QUANTIDADE`}
</Button>
{!concluida && item.saldo > 0 && (
<Button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaSelecionada(item);
setQtdApontar(item.saldo);
setModoEdicao(false);
setEtapaAtual('quantidade');
}}
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
title="Apontar todo o saldo disponível de uma vez"
>
TODAS ({item.saldo})
</Button>
)}
</>
)} )}
</div> </div>
</div> </div>
@@ -987,6 +1226,42 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
}} }}
/> />
)} )}
{/* Modal de Mapa Térmico de Kits */}
<MapaTermicoKitsModal
open={isMapaKitsOpen}
onOpenChange={setIsMapaKitsOpen}
ofNumber={obra.of_number}
fase={faseSelecionada}
onSelectPecaParaApontar={(pecaKit) => {
const ehProcCorte = processoSelecionado?.id === procCorteId;
if (ehProcCorte && pecaKit.tem_componentes && pecaKit.componentes.length > 0) {
setPecaParaCorteModal(pecaKit);
} else {
const pItem = pecasComSaldo.find((p) => p.id === pecaKit.peca_id);
if (pItem && pItem.saldo > 0) {
handleIniciarApontamentoPeca(pItem, pItem.saldo);
}
}
}}
/>
{/* Modal de Apontamento de Componentes de Corte */}
{pecaParaCorteModal && procCorteId && (
<SmartComponentesCorteModal
open={!!pecaParaCorteModal}
onOpenChange={(aberto) => {
if (!aberto) setPecaParaCorteModal(null);
}}
pecaKit={pecaParaCorteModal}
ofNumber={obra.of_number}
processoCorteId={procCorteId}
onApontamentoSalvo={() => {
carregarDadosOF();
onApontamentoRealizado(1, 0);
}}
/>
)}
</div> </div>
); );
} }
@@ -999,7 +1274,8 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
pecaSelecionada, pecaSelecionada,
processoSelecionado, processoSelecionado,
processos, processos,
mapaProducao mapaProducao,
mapaKits
); );
const saldoPendente = infoSaldoAtual.saldo; const saldoPendente = infoSaldoAtual.saldo;
const maxPermitido = modoEdicao ? infoSaldoAtual.jaProduzido + infoSaldoAtual.saldo : infoSaldoAtual.saldo; const maxPermitido = modoEdicao ? infoSaldoAtual.jaProduzido + infoSaldoAtual.saldo : infoSaldoAtual.saldo;
@@ -20,6 +20,7 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
const [modelData, setModelData] = useState<LoadedModel3DResult | null>(null); const [modelData, setModelData] = useState<LoadedModel3DResult | null>(null);
const [phasesList, setPhasesList] = useState<string[]>([]); const [phasesList, setPhasesList] = useState<string[]>([]);
const [productionMap, setProductionMap] = useState<Map<string, ProductionPieceStatus>>(new Map()); const [productionMap, setProductionMap] = useState<Map<string, ProductionPieceStatus>>(new Map());
const [initialRotation, setInitialRotation] = useState<{ x: number; y: number; z: number } | null>(null);
// Loading States // Loading States
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
@@ -35,10 +36,10 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
setLoadingPercent(15); setLoadingPercent(15);
try { try {
// 1.1. Buscar registro da OF para obter URL do IFC // 1.1. Buscar registro da OF para obter URL do IFC e rotação memorizada
const { data: ofData, error: ofError } = await supabase const { data: ofData, error: ofError } = await supabase
.from('ordens_fabricacao' as any) .from('ordens_fabricacao' as any)
.select('id, num_of, descritivo, ifc_url, ifc_filename') .select('id, num_of, descritivo, ifc_url, ifc_filename, model_3d_rotation')
.eq('num_of', obra.of_number) .eq('num_of', obra.of_number)
.maybeSingle(); .maybeSingle();
@@ -52,6 +53,9 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
const ifcUrl = (ofData as any).ifc_url; const ifcUrl = (ofData as any).ifc_url;
const ifcFilename = (ofData as any).ifc_filename; const ifcFilename = (ofData as any).ifc_filename;
if ((ofData as any).model_3d_rotation) {
setInitialRotation((ofData as any).model_3d_rotation);
}
// 1.2. Buscar Peças e Apontamentos em paralelo // 1.2. Buscar Peças e Apontamentos em paralelo
setLoadingStep('Carregando dados das peças e apontamentos...'); setLoadingStep('Carregando dados das peças e apontamentos...');
@@ -65,7 +69,7 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
supabase supabase
.from('apontamentos_producao' as any) .from('apontamentos_producao' as any)
.select(` .select(`
id, of_number, peca_id, quantidade_produzida, id, of_number, peca_id, quantidade_produzida, tipo_apontamento, componente_id,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase), peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem) processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`) `)
@@ -87,6 +91,8 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
marca: marcaStr, marca: marcaStr,
fase, fase,
etapa_fase: fase, etapa_fase: fase,
descricao: p.descricao || '',
perfilPrincipal: p.perfil_principal || p.descricao || '',
totalQtd: Number(p.quantidade || 1), totalQtd: Number(p.quantidade || 1),
pointedQtd: 0, pointedQtd: 0,
currentProcessName: 'Pendente', currentProcessName: 'Pendente',
@@ -133,6 +139,10 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
); );
sortedApontamentos.forEach((ap: any) => { sortedApontamentos.forEach((ap: any) => {
// Ignora apontamentos de componentes para não inflar as peças mestres
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
if (ap.componente_id) return;
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : ''; const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
const fase = ap.peca?.etapa_fase ? String(ap.peca.etapa_fase).trim() : ''; const fase = ap.peca?.etapa_fase ? String(ap.peca.etapa_fase).trim() : '';
if (marca) { if (marca) {
@@ -277,6 +287,7 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
selectedOF={obra.of_number} selectedOF={obra.of_number}
cliente={obra.cliente} cliente={obra.cliente}
phasesList={phasesList} phasesList={phasesList}
initialRotation={initialRotation}
onVoltar={onVoltarHub} onVoltar={onVoltarHub}
/> />
</div> </div>
+59
View File
@@ -0,0 +1,59 @@
export type DefectSeverity = 'critico' | 'alerta' | 'informativo';
export type DefectType =
| 'furo_desalinhado'
| 'furo_cego_faltante'
| 'furo_extra_revisao'
| 'furo_removido_revisao'
| 'deslocamento_furo'
| 'colisao_geometrica'
| 'peca_modificada'
| 'peca_nova'
| 'peca_excluida'
| 'divergencia_dimensional';
export type ProductionRisk = 'baixo' | 'medio' | 'alto' | 'critico';
export interface DefectItem {
id: string;
tipo: DefectType;
gravidade: DefectSeverity;
titulo: string;
descricao: string;
pecaMarca: string;
conjuntoMarca?: string;
expressID?: number;
guid?: string;
fase?: string;
quantidadeAfetada: number;
pesoUnitarioKg?: number;
pesoTotalKg?: number;
// Detalhes de furação
diametroMm?: number;
deslocamentoMm?: { x: number; y: number; z: number };
pecaConectadaMarca?: string;
pecaConectadaConjunto?: string;
// Cruzamento com chão de fábrica
processoAtual?: string;
processoCor?: string;
quantidadeApontada?: number;
riscoFabril: ProductionRisk;
recomendacao: string;
// Coordenadas aproximadas para foco 3D
coordenadas3D?: { x: number; y: number; z: number };
}
export interface ComparisonDiffSummary {
totalPecasBase: number;
totalPecasRevisao: number;
inalteradas: number;
modificadas: number;
novas: number;
excluidas: number;
inconformidadesCriticas: number;
alertasFuros: number;
pesoAfetadoKg: number;
riscoGeral: ProductionRisk;
}
export type ColorizationMode = 'status_defeito' | 'diff_revisao' | 'risco_fabril' | 'processo';
+421
View File
@@ -0,0 +1,421 @@
export interface RegraAvancada {
key: string;
nome: string;
descricao: string;
icone: string;
padraoAtivo?: boolean;
}
export interface GrupoAvancadoConfig {
key: string;
titulo: string;
cor: 'sky' | 'indigo' | 'amber' | 'teal' | 'rose';
icone: string;
descricao: string;
regras: RegraAvancada[];
templates: {
id: string;
nome: string;
descricao: string;
icone: string;
regrasAtivas: string[];
}[];
}
export const GRUPOS_AVANCADOS_CONFIG: Record<string, GrupoAvancadoConfig> = {
// 1. PRODUÇÃO & PCP
producao_pcp: {
key: 'producao_pcp',
titulo: 'Produção & PCP',
cor: 'sky',
icone: 'Factory',
descricao: 'Controle de apontamentos de fábrica, fechamento de diários e reordenação de filas.',
regras: [
{
key: 'pcp_retroativo_permitido',
nome: 'Apontamento Retroativo de Produção',
descricao: 'Permite apontar produção com datas anteriores ao dia de trabalho atual.',
icone: 'Calendar',
padraoAtivo: false,
},
{
key: 'pcp_fechar_diario',
nome: 'Fechamento & Homologação do Diário',
descricao: 'Autorização para encerrar e consolidar o diário de produção do dia.',
icone: 'Lock',
padraoAtivo: false,
},
{
key: 'pcp_reordenar_prioridades',
nome: 'Reordenação da Fila de Fabricação',
descricao: 'Permite alterar a prioridade de fabricação e a ordem de corte/montagem das OFs.',
icone: 'ArrowUpDown',
padraoAtivo: false,
},
{
key: 'pcp_apontamento_massa',
nome: 'Apontamento em Lote / Massa',
descricao: 'Libera o avanço conjunto de múltiplas peças simultaneamente.',
icone: 'Layers',
padraoAtivo: true,
},
{
key: 'pcp_excluir_apontamento',
nome: 'Exclusão & Estorno de Apontamentos',
descricao: 'Permite cancelar ou excluir apontamentos de produção já registrados.',
icone: 'Trash2',
padraoAtivo: false,
},
{
key: 'pcp_exportar_relatorios',
nome: 'Exportação de Relatórios de Produção',
descricao: 'Download de relatórios em PDF e planilhas de avanço e produtividade fabril.',
icone: 'FileSpreadsheet',
padraoAtivo: true,
},
],
templates: [
{
id: 'operador_pcp',
nome: 'Apontador Fabril Padrão',
descricao: 'Apontamento normal em lote de peças, sem autorização para exclusões ou datas retroativas.',
icone: 'Wrench',
regrasAtivas: ['pcp_apontamento_massa'],
},
{
id: 'lider_producao',
nome: 'Líder / Encarregado de Fábrica',
descricao: 'Liberação de apontamentos retroativos, reordenação de filas e exportação de relatórios.',
icone: 'Shield',
regrasAtivas: ['pcp_retroativo_permitido', 'pcp_reordenar_prioridades', 'pcp_apontamento_massa', 'pcp_exportar_relatorios'],
},
{
id: 'gestor_pcp_total',
nome: 'Planejador PCP Total',
descricao: 'Acesso pleno ao módulo: fechamento de diários, estornos, reordenação e relatórios.',
icone: 'Crown',
regrasAtivas: [
'pcp_retroativo_permitido',
'pcp_fechar_diario',
'pcp_reordenar_prioridades',
'pcp_apontamento_massa',
'pcp_excluir_apontamento',
'pcp_exportar_relatorios',
],
},
],
},
// 2. ENGENHARIA, OFS & BIM
engenharia_3d: {
key: 'engenharia_3d',
titulo: 'Engenharia, OFs & BIM',
cor: 'indigo',
icone: 'Box',
descricao: 'Criação de OFs, importação de dados Tekla/XML/DSTV, cadastro de peças e modelos 3D.',
regras: [
{
key: 'eng_criar_of',
nome: 'Criação de Nova Ordem de Fabricação (OF)',
descricao: 'Libera o botão de cadastro de novas ordens e definição de cliente e contrato.',
icone: 'PlusCircle',
padraoAtivo: false,
},
{
key: 'eng_editar_of',
nome: 'Edição de Dados & Metadados da OF',
descricao: 'Permite alterar prazos, peso contratual, cliente e informações gerais da OF.',
icone: 'Edit3',
padraoAtivo: false,
},
{
key: 'eng_excluir_of',
nome: 'Exclusão ou Cancelamento de OF',
descricao: 'Ação crítica de remoção ou cancelamento permanente de Ordens de Fabricação.',
icone: 'Trash2',
padraoAtivo: false,
},
{
key: 'eng_importar_dados_tecnicos',
nome: 'Importação de Dados Técnicos (XML / DSTV / Tekla)',
descricao: 'Upload e processamento de arquivos de engenharia e listas de peças.',
icone: 'UploadCloud',
padraoAtivo: true,
},
{
key: 'eng_gerenciar_pecas',
nome: 'Cadastro, Edição & Exclusão de Peças',
descricao: 'Permite adicionar novas marcas, ajustar perfis, comprimentos e pesos na lista técnica.',
icone: 'Network',
padraoAtivo: false,
},
{
key: 'eng_download_ifc',
nome: 'Download de Modelos 3D & Arquivos IFC',
descricao: 'Permite exportar e baixar os modelos tridimensionais brutos e arquivos IFC/BIM.',
icone: 'Download',
padraoAtivo: true,
},
{
key: 'eng_exportar_listas',
nome: 'Exportação de Listas de Materiais & Peças',
descricao: 'Emissão de relatórios técnicos, listas de corte e resumo de pesos.',
icone: 'FileText',
padraoAtivo: true,
},
],
templates: [
{
id: 'desenhista_detalhista',
nome: 'Detalhista / Projetista',
descricao: 'Focado em importação técnica, download de modelos IFC e geração de listas de corte.',
icone: 'Compass',
regrasAtivas: ['eng_importar_dados_tecnicos', 'eng_download_ifc', 'eng_exportar_listas'],
},
{
id: 'engenheiro_estrutural',
nome: 'Engenheiro de Estruturas',
descricao: 'Criação de OFs, edição técnica de peças, importação de dados e relatórios.',
icone: 'Shield',
regrasAtivas: ['eng_criar_of', 'eng_editar_of', 'eng_importar_dados_tecnicos', 'eng_gerenciar_pecas', 'eng_download_ifc', 'eng_exportar_listas'],
},
{
id: 'coordenador_engenharia_total',
nome: 'Coordenador de Engenharia Total',
descricao: 'Controle irrestrito de engenharia, incluindo exclusão e cancelamento de OFs.',
icone: 'Crown',
regrasAtivas: [
'eng_criar_of',
'eng_editar_of',
'eng_excluir_of',
'eng_importar_dados_tecnicos',
'eng_gerenciar_pecas',
'eng_download_ifc',
'eng_exportar_listas',
],
},
],
},
// 3. EXPEDIÇÃO, ESTOQUE & ROMANEIOS
expedicao_logistica: {
key: 'expedicao_logistica',
titulo: 'Expedição, Estoque & Romaneios',
cor: 'amber',
icone: 'Truck',
descricao: 'Gestão de cargas, romaneios de embarque, controle de estoque e solicitações de compra.',
regras: [
{
key: 'exp_criar_romaneio',
nome: 'Criação de Cargas & Romaneios',
descricao: 'Permite abrir uma nova carga de expedição e definir transportadora e motorista.',
icone: 'PlusSquare',
padraoAtivo: false,
},
{
key: 'exp_adicionar_remover_pecas',
nome: 'Montagem de Romaneio (Adicionar/Remover Peças)',
descricao: 'Autorização para incluir ou retirar peças e conjuntos da carga.',
icone: 'PackageCheck',
padraoAtivo: true,
},
{
key: 'exp_despachar_carga',
nome: 'Despacho & Finalização de Romaneio',
descricao: 'Permite concluir o romaneio e marcar a carga como despachada/enviada.',
icone: 'CheckSquare',
padraoAtivo: false,
},
{
key: 'exp_cancelar_estornar',
nome: 'Cancelamento & Estorno de Cargas Despachadas',
descricao: 'Ação crítica de cancelar romaneios enviados, com devolução das peças ao pátio.',
icone: 'RotateCcw',
padraoAtivo: false,
},
{
key: 'exp_imprimir_documentos',
nome: 'Emissão de Romaneios PDF & Etiquetas',
descricao: 'Permite imprimir o espelho do romaneio e etiquetas de identificação de peças.',
icone: 'Printer',
padraoAtivo: true,
},
{
key: 'est_ajuste_manual_saldo',
nome: 'Ajuste Manual de Saldo de Estoque',
descricao: 'Permite alterar quantidades físicas de chapas, perfis e consumíveis no inventário.',
icone: 'SlidersHorizontal',
padraoAtivo: false,
},
{
key: 'est_entrada_material',
nome: 'Entrada de Matérias-Primas & NFs',
descricao: 'Registra novas entradas de aço no estoque de fábrica.',
icone: 'FileInput',
padraoAtivo: false,
},
{
key: 'est_solicitacao_compras',
nome: 'Criação & Gestão de Solicitações de Compras',
descricao: 'Permite solicitar aquisição de materiais e insumos para suprimento das OFs.',
icone: 'ShoppingCart',
padraoAtivo: false,
},
],
templates: [
{
id: 'operador_expedicao',
nome: 'Conferente de Pátio / Expedição',
descricao: 'Adição de peças no romaneio e impressão de espelhos e etiquetas.',
icone: 'Barcode',
regrasAtivas: ['exp_adicionar_remover_pecas', 'exp_imprimir_documentos'],
},
{
id: 'almoxarife_estoquista',
nome: 'Almoxarife / Estoquista',
descricao: 'Entradas de aço, controle de saldo e solicitações de compras.',
icone: 'Box',
regrasAtivas: ['est_entrada_material', 'est_solicitacao_compras', 'exp_imprimir_documentos'],
},
{
id: 'lider_logistica_total',
nome: 'Gestor de Logística & Estoque Total',
descricao: 'Controle total de cargas, despacho, estorno, inventário e compras.',
icone: 'Crown',
regrasAtivas: [
'exp_criar_romaneio',
'exp_adicionar_remover_pecas',
'exp_despachar_carga',
'exp_cancelar_estornar',
'exp_imprimir_documentos',
'est_ajuste_manual_saldo',
'est_entrada_material',
'est_solicitacao_compras',
],
},
],
},
// 4. OBRA & MONTAGEM EXTERNA
obra_montagem: {
key: 'obra_montagem',
titulo: 'Obra & Montagem Externa',
cor: 'teal',
icone: 'HardHat',
descricao: 'Acompanhamento do canteiro, status de montagem de peças e diário de obra.',
regras: [
{
key: 'obra_apontar_montagem',
nome: 'Apontamento de Peças Montadas em Campo',
descricao: 'Permite registrar que conjuntos e peças foram descarregados e montados no canteiro.',
icone: 'CheckCheck',
padraoAtivo: true,
},
{
key: 'obra_registrar_ocorrencia',
nome: 'Registro de Ocorrências & Diário de Campo',
descricao: 'Permite anotar paralisações por clima, problemas de montagem e observações de obra.',
icone: 'AlertCircle',
padraoAtivo: true,
},
{
key: 'obra_configurar_marcos',
nome: 'Configuração de Frentes & Marcos da Obra',
descricao: 'Permite criar e alterar etapas, setores e marcos estruturais do projeto.',
icone: 'Settings2',
padraoAtivo: false,
},
{
key: 'obra_exportar_relatorio',
nome: 'Exportação de Relatórios de Evolução de Obra',
descricao: 'Geração de relatórios gerenciais e evolução percentual da montagem.',
icone: 'FileSpreadsheet',
padraoAtivo: true,
},
],
templates: [
{
id: 'montador_campo',
nome: 'Montador / Encarregado de Campo',
descricao: 'Apontamento de peças no canteiro e registro de ocorrências.',
icone: 'Wrench',
regrasAtivas: ['obra_apontar_montagem', 'obra_registrar_ocorrencia'],
},
{
id: 'engenheiro_obra_total',
nome: 'Engenheiro Residente da Obra',
descricao: 'Acesso irrestrito com configuração de marcos e exportação de relatórios.',
icone: 'Crown',
regrasAtivas: ['obra_apontar_montagem', 'obra_registrar_ocorrencia', 'obra_configurar_marcos', 'obra_exportar_relatorio'],
},
],
},
// 5. ADMINISTRAÇÃO & SISTEMA
admin_sistema: {
key: 'admin_sistema',
titulo: 'Administração & Sistema',
cor: 'rose',
icone: 'Shield',
descricao: 'Governança de acessos, usuários, auditoria de dados e configurações da empresa.',
regras: [
{
key: 'adm_gerenciar_usuarios',
nome: 'Gestão de Usuários & Aprovações',
descricao: 'Permite criar, editar, aprovar novos cadastros e inativar colaboradores.',
icone: 'Users',
padraoAtivo: false,
},
{
key: 'adm_gerenciar_matriz',
nome: 'Gestão da Matriz de Acessos & Permissões',
descricao: 'Autorização para conceder ou revogar acessos básicos e regras avançadas.',
icone: 'ShieldCheck',
padraoAtivo: false,
},
{
key: 'adm_redefinir_senhas',
nome: 'Redefinição de Senhas de Acesso',
descricao: 'Permite gerar links de recuperação e redefinir senhas de colaboradores.',
icone: 'KeyRound',
padraoAtivo: false,
},
{
key: 'adm_auditoria_inconsistencias',
nome: 'Auditoria & Correção de Inconsistências',
descricao: 'Executa varreduras de integridade no banco de dados e aplica correções automáticas.',
icone: 'Activity',
padraoAtivo: false,
},
{
key: 'adm_configuracoes_gerais',
nome: 'Alteração de Parâmetros da Empresa',
descricao: 'Autoriza alterar dados cadastrais da empresa, logotipo e parâmetros globais do ERP.',
icone: 'Settings',
padraoAtivo: false,
},
],
templates: [
{
id: 'administrador_rh',
nome: 'Gestor de Equipe & RH',
descricao: 'Criação de usuários, aprovação de pendentes e redefinição de senhas.',
icone: 'Users',
regrasAtivas: ['adm_gerenciar_usuarios', 'adm_redefinir_senhas'],
},
{
id: 'super_admin_total',
nome: 'Super Administrador Total',
descricao: 'Acesso irrestrito a todas as configurações de governança, sistema e banco de dados.',
icone: 'Crown',
regrasAtivas: [
'adm_gerenciar_usuarios',
'adm_gerenciar_matriz',
'adm_redefinir_senhas',
'adm_auditoria_inconsistencias',
'adm_configuracoes_gerais',
],
},
],
},
};
+76
View File
@@ -0,0 +1,76 @@
export type NivelRiscoImportacao = 'nenhum' | 'baixo' | 'medio' | 'critico';
export interface DiffCampo {
campo: string;
label: string;
valorAntigo: any;
valorNovo: any;
isCritico: boolean;
}
export interface DiffComponente {
marca_componente: string;
descricao?: string;
perfil?: string;
tipo: 'adicionado' | 'removido' | 'modificado' | 'inalterado';
detalhe?: string;
quantidadeAntiga?: number;
quantidadeNova?: number;
comprimentoAntigo?: number | null;
comprimentoNovo?: number | null;
pesoUnitAntigo?: number | null;
pesoUnitNovo?: number | null;
}
export interface ProcessoApontado {
processoId: string;
processoNome: string;
ordem: number;
quantidadeProduzida: number;
}
export interface ApontamentoResumoPeca {
totalProduzido: number;
processos: ProcessoApontado[];
ultimoProcessoNome?: string;
temApontamento: boolean;
emRomaneio: boolean;
}
export interface ItemAnaliseImportacao {
key: string; // `${of_number}-${etapa_fase}-${marca}`
of_number: string;
etapa_fase: string;
marca: string;
descricao: string;
isNova: boolean;
isIdentica: boolean;
temDivergencias: boolean;
camposDivergentes: DiffCampo[];
componentesDivergentes: DiffComponente[];
dadosImportados: Record<string, any>;
dadosExistentes?: Record<string, any>;
pecaIdExistente?: string;
apontamentoResumo: ApontamentoResumoPeca;
nivelRisco: NivelRiscoImportacao;
// Ações de usuário selecionadas
sobrepor: boolean; // true para atualizar no banco, false para ignorar
atencaoEspecial: boolean; // true se deve gerar ordem de intervenção impressa
motivoAlerta?: string;
acaoRecomendadaFabril?: string;
}
export interface ResultadoAnaliseImportacao {
totalImportadas: number;
totalNovas: number;
totalIdenticas: number;
totalComDivergencia: number;
totalComApontamento: number;
totalCriticas: number;
itens: ItemAnaliseImportacao[];
}
+220
View File
@@ -0,0 +1,220 @@
export interface SmartSubmodule {
key: string;
nome: string;
descricao: string;
icone: string;
padraoOperador: {
can_view: boolean;
can_edit: boolean;
can_delete: boolean;
};
}
export const SMART_SUBMODULES: SmartSubmodule[] = [
{
key: 'smart_of',
nome: 'Ordens de Fabricação (OF)',
descricao: 'Acesso à seleção, listagem e busca de OFs ativas',
icone: 'FileText',
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
},
{
key: 'smart_fases',
nome: 'Fases & Etapas da Obra',
descricao: 'Filtro e navegação pelas etapas/fases de fabricação',
icone: 'Layers',
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
},
{
key: 'smart_processos',
nome: 'Processos de Fabricação',
descricao: 'Visualização e seleção de processos (Corte, Solda, Pintura, etc.)',
icone: 'Flame',
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
},
{
key: 'smart_apontamento',
nome: 'Apontamento de Peças',
descricao: 'Realização de apontamentos normais de produção no chão de fábrica',
icone: 'CheckCircle2',
padraoOperador: { can_view: true, can_edit: true, can_delete: false },
},
{
key: 'smart_forcar_apontamento',
nome: 'Forçar Apontamento (Avanço sem restrição)',
descricao: 'Permissão para avançar peças furando fila ou com pendências de processos anteriores',
icone: 'Zap',
padraoOperador: { can_view: false, can_edit: false, can_delete: false },
},
{
key: 'smart_3d',
nome: 'Modelo 3D BIM',
descricao: 'Visualizador interativo 3D de peças, montagens e conjuntos',
icone: 'Box',
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
},
{
key: 'smart_modo_completo',
nome: 'Acesso ao Modo Completo ERP',
descricao: 'Permissão para alternar da interface touch para o ERP gerencial',
icone: 'ExternalLink',
padraoOperador: { can_view: false, can_edit: false, can_delete: false },
},
{
key: 'smart_romaneios',
nome: 'Romaneios & Embarque',
descricao: 'Acesso e conferência de romaneios de expedição de peças',
icone: 'Truck',
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
},
{
key: 'smart_montagem',
nome: 'Montagem & Canteiro',
descricao: 'Apontamentos e checklists de montagem externa',
icone: 'Wrench',
padraoOperador: { can_view: true, can_edit: true, can_delete: false },
},
{
key: 'smart_relatorios',
nome: 'Relatórios & Exportação PDF',
descricao: 'Geração de resumos de produção, checklists e exportação de relatórios',
icone: 'FileSpreadsheet',
padraoOperador: { can_view: true, can_edit: true, can_delete: false },
},
];
export interface SmartCustomTemplate {
id: string;
nome: string;
descricao: string;
icone: string;
badge?: string;
isCustom?: boolean;
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>;
}
export const SMART_DEFAULT_TEMPLATES: SmartCustomTemplate[] = [
{
id: 'operador_padrao',
nome: 'Padrão Operador',
descricao: 'Focado em apontamento de peças, montagem e relatórios básicos.',
icone: 'Factory',
badge: 'Chão de Fábrica',
isCustom: false,
permissions: {
smart_of: { can_view: true, can_edit: false, can_delete: false },
smart_fases: { can_view: true, can_edit: false, can_delete: false },
smart_processos: { can_view: true, can_edit: false, can_delete: false },
smart_apontamento: { can_view: true, can_edit: true, can_delete: false },
smart_forcar_apontamento: { can_view: false, can_edit: false, can_delete: false },
smart_3d: { can_view: true, can_edit: false, can_delete: false },
smart_modo_completo: { can_view: false, can_edit: false, can_delete: false },
smart_romaneios: { can_view: true, can_edit: false, can_delete: false },
smart_montagem: { can_view: true, can_edit: true, can_delete: false },
smart_relatorios: { can_view: true, can_edit: true, can_delete: false },
},
},
{
id: 'lider_producao',
nome: 'Líder de Produção',
descricao: 'Permite forçar apontamentos com pendências e gerar todos os relatórios.',
icone: 'Zap',
badge: 'Liderança',
isCustom: false,
permissions: {
smart_of: { can_view: true, can_edit: true, can_delete: false },
smart_fases: { can_view: true, can_edit: true, can_delete: false },
smart_processos: { can_view: true, can_edit: true, can_delete: false },
smart_apontamento: { can_view: true, can_edit: true, can_delete: true },
smart_forcar_apontamento: { can_view: true, can_edit: true, can_delete: false },
smart_3d: { can_view: true, can_edit: false, can_delete: false },
smart_modo_completo: { can_view: true, can_edit: false, can_delete: false },
smart_romaneios: { can_view: true, can_edit: true, can_delete: false },
smart_montagem: { can_view: true, can_edit: true, can_delete: false },
smart_relatorios: { can_view: true, can_edit: true, can_delete: false },
},
},
{
id: 'inspetor_qualidade',
nome: 'Inspetor de Qualidade',
descricao: 'Acesso ao 3D, relatórios, romaneios e visualização completa de processos.',
icone: 'CheckCircle2',
badge: 'Qualidade',
isCustom: false,
permissions: {
smart_of: { can_view: true, can_edit: false, can_delete: false },
smart_fases: { can_view: true, can_edit: false, can_delete: false },
smart_processos: { can_view: true, can_edit: false, can_delete: false },
smart_apontamento: { can_view: true, can_edit: false, can_delete: false },
smart_forcar_apontamento: { can_view: false, can_edit: false, can_delete: false },
smart_3d: { can_view: true, can_edit: false, can_delete: false },
smart_modo_completo: { can_view: false, can_edit: false, can_delete: false },
smart_romaneios: { can_view: true, can_edit: false, can_delete: false },
smart_montagem: { can_view: true, can_edit: false, can_delete: false },
smart_relatorios: { can_view: true, can_edit: true, can_delete: false },
},
},
{
id: 'total_admin',
nome: 'Total Admin',
descricao: 'Acesso irrestrito a todos os submódulos, forçar apontamentos e modo completo.',
icone: 'Shield',
badge: 'Administrador',
isCustom: false,
permissions: {
smart_of: { can_view: true, can_edit: true, can_delete: true },
smart_fases: { can_view: true, can_edit: true, can_delete: true },
smart_processos: { can_view: true, can_edit: true, can_delete: true },
smart_apontamento: { can_view: true, can_edit: true, can_delete: true },
smart_forcar_apontamento: { can_view: true, can_edit: true, can_delete: true },
smart_3d: { can_view: true, can_edit: true, can_delete: true },
smart_modo_completo: { can_view: true, can_edit: true, can_delete: true },
smart_romaneios: { can_view: true, can_edit: true, can_delete: true },
smart_montagem: { can_view: true, can_edit: true, can_delete: true },
smart_relatorios: { can_view: true, can_edit: true, can_delete: true },
},
},
{
id: 'apenas_leitura',
nome: 'Apenas Leitura',
descricao: 'Somente visualização de OFs, peças, 3D e status sem permissão de apontamento.',
icone: 'Eye',
badge: 'Consulta',
isCustom: false,
permissions: {
smart_of: { can_view: true, can_edit: false, can_delete: false },
smart_fases: { can_view: true, can_edit: false, can_delete: false },
smart_processos: { can_view: true, can_edit: false, can_delete: false },
smart_apontamento: { can_view: false, can_edit: false, can_delete: false },
smart_forcar_apontamento: { can_view: false, can_edit: false, can_delete: false },
smart_3d: { can_view: true, can_edit: false, can_delete: false },
smart_modo_completo: { can_view: false, can_edit: false, can_delete: false },
smart_romaneios: { can_view: true, can_edit: false, can_delete: false },
smart_montagem: { can_view: true, can_edit: false, can_delete: false },
smart_relatorios: { can_view: true, can_edit: false, can_delete: false },
},
},
];
export interface SmartUserPermission {
id?: string;
user_id: string;
resource_key: string;
can_view: boolean;
can_edit: boolean;
can_delete: boolean;
}
export interface SmartUserConfig {
userId: string;
email: string;
fullName: string;
functionName: string;
profileImageUrl: string | null;
whatsappNumber: string;
notifEmailApontamentos: boolean;
notifEmailMetricas: boolean;
notifWhatsappApontamentos: boolean;
notifWhatsappMetricas: boolean;
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>;
}
+633
View File
@@ -0,0 +1,633 @@
import { supabase } from '@/integrations/supabase/client';
import {
DiffCampo,
DiffComponente,
ItemAnaliseImportacao,
NivelRiscoImportacao,
ProcessoApontado,
ResultadoAnaliseImportacao,
} from '@/types/importDiff';
interface CampoConfig {
key: string;
label: string;
isCritico: boolean;
isNumeric?: boolean;
}
const CAMPOS_MONITORADOS: CampoConfig[] = [
{ key: 'quantidade', label: 'Quantidade (un)', isCritico: true, isNumeric: true },
{ key: 'comprimento', label: 'Comprimento (mm)', isCritico: true, isNumeric: true },
{ key: 'perfil_principal', label: 'Perfil / Bitola', isCritico: true },
{ key: 'peso_unitario', label: 'Peso Unitário (kg)', isCritico: true, isNumeric: true },
{ key: 'peso_total', label: 'Peso Total (kg)', isCritico: true, isNumeric: true },
{ key: 'material', label: 'Material', isCritico: false },
{ key: 'tratamento_superficial', label: 'Tratamento', isCritico: false },
{ key: 'descricao', label: 'Descrição', isCritico: false },
{ key: 'tem_componentes', label: 'Tem Componentes', isCritico: true },
];
/**
* Função utilitária resiliente para extrair o comprimento numérico de uma peça principal.
* Suporta múltiplos aliases de importação (Bocad, Advance Steel, Excel, CSV) e formatações de string.
*/
export function extrairComprimentoSeguro(item: any): number | null {
if (item === undefined || item === null || item === '' || item === '-') return null;
let raw: any;
if (typeof item === 'number' || typeof item === 'string') {
raw = item;
} else if (typeof item === 'object') {
raw =
item.comprimento ??
item.comprimento_ref ??
item.comprimentoRef ??
item.comprimentoMax ??
item.comprimento_max ??
item.comprimento_peca ??
item['Comprimento'] ??
item['Comprimento (mm)'] ??
item['Comprimento Ref. (mm)'] ??
item['Comprimento Max. (mm)'] ??
item['Comp. (mm)'] ??
item['Comp (mm)'] ??
item.compr ??
item.comp;
}
if (raw === undefined || raw === null || raw === '' || raw === '-') return null;
if (typeof raw === 'number') {
return isNaN(raw) || raw <= 0 ? null : Math.round(raw);
}
// String cleanup: trata formatos como "3.241", "3,241", "6000 mm", "5850.5"
let str = String(raw).trim().replace(/mm/gi, '').trim();
if (str.includes('.') && str.includes(',')) {
if (str.indexOf('.') < str.indexOf(',')) {
str = str.replace(/\./g, '').replace(',', '.');
} else {
str = str.replace(/,/g, '');
}
} else if (str.includes(',')) {
str = str.replace(',', '.');
}
const num = parseFloat(str);
return isNaN(num) || num <= 0 ? null : Math.round(num);
}
/**
* Função utilitária resiliente para extrair o comprimento numérico de subcomponentes.
*/
export function extrairComprimentoComponenteSeguro(item: any): number | null {
if (item === undefined || item === null || item === '' || item === '-') return null;
let raw: any;
if (typeof item === 'number' || typeof item === 'string') {
raw = item;
} else if (typeof item === 'object') {
raw =
item.comprimento_componente ??
item.comprimentoComponente ??
item.comprimento ??
item.comprimento_ref ??
item.comprimentoRef ??
item.compr ??
item.comp ??
item['Comprimento Componente'] ??
item['Comprimento (mm)'] ??
item['Comprimento'] ??
item['Comp. (mm)'];
}
if (raw === undefined || raw === null || raw === '' || raw === '-') return null;
if (typeof raw === 'number') {
return isNaN(raw) || raw <= 0 ? null : Math.round(raw);
}
let str = String(raw).trim().replace(/mm/gi, '').trim();
if (str.includes('.') && str.includes(',')) {
if (str.indexOf('.') < str.indexOf(',')) {
str = str.replace(/\./g, '').replace(',', '.');
} else {
str = str.replace(/,/g, '');
}
} else if (str.includes(',')) {
str = str.replace(',', '.');
}
const num = parseFloat(str);
return isNaN(num) || num <= 0 ? null : Math.round(num);
}
/**
* Normaliza e agrupa linhas de peças e subcomponentes
*/
export function consolidarPecasEComponentes(rawItems: any[]): {
pecasMap: Map<string, any>;
componentesMap: Map<string, any[]>;
} {
const pecasMap = new Map<string, any>();
const componentesMap = new Map<string, any[]>();
rawItems.forEach((item) => {
const ofNumber = String(item.of_number || '').trim();
const etapaFase = String(item.etapa_fase || '1').trim();
const marca = String(item.marca || '').trim();
if (!marca) return;
const pecaKey = `${ofNumber}-${etapaFase}-${marca}`;
const comprimentoFinal = extrairComprimentoSeguro(item);
const pecaData = {
of_number: ofNumber,
etapa_fase: etapaFase,
marca: marca,
descricao: item.descricao || item.perfil_principal || '',
quantidade: Number(item.quantidade) || 0,
peso_unitario: Number(item.peso_unitario) || 0,
peso_total: Number(item.peso_total) || 0,
comprimento: comprimentoFinal,
tratamento_superficial: item.tratamento_superficial || 'pintura',
material: item.material || 'Aço A36',
perfil_principal: item.perfil_principal || item.descricao || '',
tem_componentes: Boolean(item.tem_componentes),
};
if (!pecasMap.has(pecaKey)) {
pecasMap.set(pecaKey, pecaData);
} else if (comprimentoFinal && !pecasMap.get(pecaKey).comprimento) {
// Se a peça já estava no mapa mas sem comprimento e esta linha tem, atualiza
pecasMap.get(pecaKey).comprimento = comprimentoFinal;
}
// Se o item contém um subcomponente
if (item.marca_componente && String(item.marca_componente).trim()) {
const rawMarca = String(item.marca_componente).trim();
let cleanMarca = rawMarca;
const matchSuffix = rawMarca.match(/-?(\d{4,})$/) || rawMarca.match(/(\d{4,})/);
if (matchSuffix) {
cleanMarca = matchSuffix[1];
} else {
const digits = rawMarca.replace(/\D/g, '');
if (digits.length >= 4) {
cleanMarca = digits.slice(-4);
} else {
cleanMarca = String(1000 + (componentesMap.get(pecaKey)?.length || 0));
}
}
let numVal = parseInt(cleanMarca, 10);
if (isNaN(numVal) || numVal < 1000 || numVal > 9999) {
numVal = 1000 + (componentesMap.get(pecaKey)?.length || 0);
}
cleanMarca = String(numVal);
const compComprimento = extrairComprimentoComponenteSeguro(item);
const compData = {
marca_componente: cleanMarca,
descricao: item.descricao_componente || item.descricao || '',
perfil: item.perfil_componente || item.perfil_principal || '',
peso_unitario: Number(item.peso_unitario_componente) || 0,
comprimento: compComprimento,
quantidade_por_peca: Math.max(1, Math.round(Number(item.quantidade_por_peca) || 1)),
};
if (!componentesMap.has(pecaKey)) {
componentesMap.set(pecaKey, []);
}
// Evita duplicatas idênticas no mesmo lote
const comps = componentesMap.get(pecaKey)!;
const exists = comps.some((c) => c.marca_componente === cleanMarca);
if (!exists) {
comps.push(compData);
} else if (compComprimento) {
// Se já existia mas sem comprimento, atualiza
const cExistente = comps.find((c) => c.marca_componente === cleanMarca);
if (cExistente && !cExistente.comprimento) {
cExistente.comprimento = compComprimento;
}
}
}
});
return { pecasMap, componentesMap };
}
/**
* Analisa a lista de importação e cruza com a base de dados
*/
export async function analisarConflitosImportacao(
rawItems: any[]
): Promise<ResultadoAnaliseImportacao> {
const { pecasMap, componentesMap } = consolidarPecasEComponentes(rawItems);
const pecasImportadas = Array.from(pecasMap.values());
if (pecasImportadas.length === 0) {
return {
totalImportadas: 0,
totalNovas: 0,
totalIdenticas: 0,
totalComDivergencia: 0,
totalComApontamento: 0,
totalCriticas: 0,
itens: [],
};
}
// 1. Extrair OFs únicas
const ofsUnicas = Array.from(
new Set(pecasImportadas.map((p) => p.of_number).filter(Boolean))
);
// 2. Buscar peças existentes no Supabase
let pecasExistentes: any[] = [];
if (ofsUnicas.length > 0) {
const { data, error } = await supabase
.from('pecas')
.select('*')
.in('of_number', ofsUnicas);
if (error) {
console.error('❌ Erro ao consultar peças existentes para diff:', error);
} else {
pecasExistentes = data || [];
}
}
// Mapear peças existentes por chave
const existingMap = new Map<string, any>();
const existingIds: string[] = [];
pecasExistentes.forEach((p) => {
const key = `${p.of_number || ''}-${p.etapa_fase || ''}-${p.marca || ''}`;
existingMap.set(key, p);
existingIds.push(p.id);
});
// 3. Buscar componentes existentes
const existingComponentsByPecaId = new Map<string, any[]>();
if (existingIds.length > 0) {
const { data: compData, error: compErr } = await supabase
.from('componentes_peca')
.select('*')
.in('peca_id', existingIds);
if (!compErr && compData) {
compData.forEach((c) => {
if (!existingComponentsByPecaId.has(c.peca_id)) {
existingComponentsByPecaId.set(c.peca_id, []);
}
existingComponentsByPecaId.get(c.peca_id)!.push(c);
});
}
}
// 4. Buscar apontamentos de produção existentes
const apontamentosByPecaId = new Map<string, any[]>();
if (existingIds.length > 0) {
const { data: aptData, error: aptErr } = await supabase
.from('apontamentos_producao')
.select(`
id,
peca_id,
processo_id,
quantidade_produzida,
data_apontamento,
processo:processos_fabricacao(id, nome, ordem)
`)
.in('peca_id', existingIds);
if (!aptErr && aptData) {
aptData.forEach((a) => {
if (!apontamentosByPecaId.has(a.peca_id)) {
apontamentosByPecaId.set(a.peca_id, []);
}
apontamentosByPecaId.get(a.peca_id)!.push(a);
});
}
}
// 5. Buscar romaneios de expedição
const pecaIdsEmRomaneio = new Set<string>();
if (existingIds.length > 0) {
const { data: romData, error: romErr } = await supabase
.from('itens_romaneio_pecas')
.select('peca_id')
.in('peca_id', existingIds);
if (!romErr && romData) {
romData.forEach((r) => pecaIdsEmRomaneio.add(r.peca_id));
}
}
// 6. Construir a análise detalhada item a item
const itensResultado: ItemAnaliseImportacao[] = [];
for (const pecaImportada of pecasImportadas) {
const key = `${pecaImportada.of_number || ''}-${pecaImportada.etapa_fase || ''}-${pecaImportada.marca || ''}`;
const pecaExistente = existingMap.get(key);
const incomingComps = componentesMap.get(key) || [];
if (!pecaExistente) {
// Peça NOVA
itensResultado.push({
key,
of_number: pecaImportada.of_number,
etapa_fase: pecaImportada.etapa_fase,
marca: pecaImportada.marca,
descricao: pecaImportada.descricao || pecaImportada.perfil_principal || '',
isNova: true,
isIdentica: false,
temDivergencias: false,
camposDivergentes: [],
componentesDivergentes: incomingComps.map((c) => ({
marca_componente: c.marca_componente,
descricao: c.descricao,
perfil: c.perfil,
tipo: 'adicionado',
quantidadeNova: c.quantidade_por_peca,
comprimentoNovo: c.comprimento,
detalhe: 'Novo componente na peça',
})),
dadosImportados: pecaImportada,
apontamentoResumo: {
totalProduzido: 0,
processos: [],
temApontamento: false,
emRomaneio: false,
},
nivelRisco: 'nenhum',
sobrepor: true,
atencaoEspecial: false,
});
continue;
}
// Peça EXISTENTE: Comparar campos
const pecaId = pecaExistente.id;
const camposDivergentes: DiffCampo[] = [];
CAMPOS_MONITORADOS.forEach(({ key: campoKey, label, isCritico, isNumeric }) => {
let valAntigo = pecaExistente[campoKey];
let valNovo = pecaImportada[campoKey];
if (isNumeric) {
valAntigo = valAntigo !== null && valAntigo !== undefined ? Number(valAntigo) : null;
valNovo = valNovo !== null && valNovo !== undefined ? Number(valNovo) : null;
if (valAntigo !== null && valNovo !== null) {
if (Math.abs(valAntigo - valNovo) > 0.001) {
camposDivergentes.push({
campo: campoKey,
label,
valorAntigo: valAntigo,
valorNovo: valNovo,
isCritico,
});
}
} else if (valAntigo !== valNovo) {
camposDivergentes.push({
campo: campoKey,
label,
valorAntigo: valAntigo,
valorNovo: valNovo,
isCritico,
});
}
} else {
const strAntigo = String(valAntigo || '').trim().toLowerCase();
const strNovo = String(valNovo || '').trim().toLowerCase();
if (strAntigo !== strNovo) {
camposDivergentes.push({
campo: campoKey,
label,
valorAntigo: pecaExistente[campoKey] || '-',
valorNovo: pecaImportada[campoKey] || '-',
isCritico,
});
}
}
});
// Comparar componentes existentes vs importados
const existingComps = existingComponentsByPecaId.get(pecaId) || [];
const componentesDivergentes: DiffComponente[] = [];
const existingCompsMap = new Map<string, any>();
existingComps.forEach((c) => existingCompsMap.set(String(c.marca_componente).trim(), c));
const incomingCompsMap = new Map<string, any>();
incomingComps.forEach((c) => incomingCompsMap.set(String(c.marca_componente).trim(), c));
// Verificar adicionados ou modificados
incomingComps.forEach((inc) => {
const matchMarca = String(inc.marca_componente).trim();
const ext = existingCompsMap.get(matchMarca);
if (!ext) {
componentesDivergentes.push({
marca_componente: matchMarca,
descricao: inc.descricao,
perfil: inc.perfil,
tipo: 'adicionado',
quantidadeNova: inc.quantidade_por_peca,
comprimentoNovo: inc.comprimento,
detalhe: `Novo componente Pos. ${matchMarca} adicionado`,
});
} else {
const mudouQtd = Number(ext.quantidade_por_peca || 1) !== Number(inc.quantidade_por_peca || 1);
const mudouCompr =
ext.comprimento !== null && inc.comprimento !== null
? Math.abs(Number(ext.comprimento) - Number(inc.comprimento)) > 0.1
: ext.comprimento !== inc.comprimento;
const mudouPerfil =
String(ext.perfil || '').trim().toLowerCase() !== String(inc.perfil || '').trim().toLowerCase();
if (mudouQtd || mudouCompr || mudouPerfil) {
const detalhes: string[] = [];
if (mudouQtd) detalhes.push(`Qtd: ${ext.quantidade_por_peca} ➔ ${inc.quantidade_por_peca}`);
if (mudouCompr) detalhes.push(`Compr: ${ext.comprimento || 0} ➔ ${inc.comprimento || 0} mm`);
if (mudouPerfil) detalhes.push(`Perfil: ${ext.perfil} ➔ ${inc.perfil}`);
componentesDivergentes.push({
marca_componente: matchMarca,
descricao: inc.descricao || ext.descricao,
perfil: inc.perfil || ext.perfil,
tipo: 'modificado',
quantidadeAntiga: ext.quantidade_por_peca,
quantidadeNova: inc.quantidade_por_peca,
comprimentoAntigo: ext.comprimento,
comprimentoNovo: inc.comprimento,
detalhe: detalhes.join(' | '),
});
}
}
});
// Verificar componentes removidos
existingComps.forEach((ext) => {
const matchMarca = String(ext.marca_componente).trim();
if (!incomingCompsMap.has(matchMarca)) {
componentesDivergentes.push({
marca_componente: matchMarca,
descricao: ext.descricao,
perfil: ext.perfil,
tipo: 'removido',
quantidadeAntiga: ext.quantidade_por_peca,
comprimentoAntigo: ext.comprimento,
detalhe: `Componente Pos. ${matchMarca} removido na nova lista`,
});
}
});
// Analisar Apontamentos Fabris
const apontamentosPeca = apontamentosByPecaId.get(pecaId) || [];
const processosMap = new Map<string, ProcessoApontado>();
apontamentosPeca.forEach((apt) => {
const procNome = apt.processo?.nome || 'Geral';
const procOrdem = apt.processo?.ordem || 99;
const procId = apt.processo_id;
if (!processosMap.has(procId)) {
processosMap.set(procId, {
processoId: procId,
processoNome: procNome,
ordem: procOrdem,
quantidadeProduzida: 0,
});
}
processosMap.get(procId)!.quantidadeProduzida += Number(apt.quantidade_produzida || 0);
});
const processosApontados = Array.from(processosMap.values()).sort(
(a, b) => a.ordem - b.ordem
);
const totalProduzidoMax = processosApontados.reduce(
(max, p) => Math.max(max, p.quantidadeProduzida),
0
);
const ultimoProc =
processosApontados.length > 0
? processosApontados[processosApontados.length - 1].processoNome
: undefined;
const temApontamento = apontamentosPeca.length > 0;
const emRomaneio = pecaIdsEmRomaneio.has(pecaId);
const temDivergencias = camposDivergentes.length > 0 || componentesDivergentes.length > 0;
const isIdentica = !temDivergencias;
// Calcular nível de risco
let nivelRisco: NivelRiscoImportacao = 'nenhum';
let motivoAlerta = '';
let acaoRecomendadaFabril = '';
if (temDivergencias) {
const temAlteracaoCritica =
camposDivergentes.some((c) => c.isCritico) || componentesDivergentes.length > 0;
if (!temApontamento && !emRomaneio) {
nivelRisco = 'baixo';
motivoAlerta = 'Peça ainda não iniciada na fábrica. Atualização cadastral limpa.';
acaoRecomendadaFabril = 'Nenhuma ação necessária no galpão.';
} else {
// Já tem apontamento ou romaneio
if (emRomaneio) {
nivelRisco = 'critico';
motivoAlerta = `ALERTA MÁXIMO: Peça já foi expedida ou carregada em romaneio!`;
acaoRecomendadaFabril =
'Acionar PCP e Engenharia de Obra imediatamente para conferência do lote expedido.';
} else if (temAlteracaoCritica) {
nivelRisco = 'critico';
const etapaStr = ultimoProc ? `no setor de ${ultimoProc.toUpperCase()}` : 'na produção';
motivoAlerta = `Peça em andamento ${etapaStr} (${totalProduzidoMax} un produzidas) com alterações físicas/dimensionais.`;
const compReduzido = camposDivergentes.find(
(c) => c.campo === 'comprimento' && Number(c.valorNovo) < Number(c.valorAntigo)
);
const compAumentado = camposDivergentes.find(
(c) => c.campo === 'comprimento' && Number(c.valorNovo) > Number(c.valorAntigo)
);
const qtdReduzida = camposDivergentes.find(
(c) => c.campo === 'quantidade' && Number(c.valorNovo) < Number(c.valorAntigo)
);
if (compReduzido) {
acaoRecomendadaFabril = `Retornar peça para corte/serra: comprimento deve ser reduzido em ${Math.abs(
Number(compReduzido.valorAntigo) - Number(compReduzido.valorNovo)
)} mm.`;
} else if (compAumentado) {
acaoRecomendadaFabril = `ATENÇÃO: Peça cresceu ${
Number(compAumentado.valorNovo) - Number(compAumentado.valorAntigo)
} mm. Descartar/reaproveitar corte antigo e cortar nova barra.`;
} else if (qtdReduzida) {
acaoRecomendadaFabril = `Redução de lote: verificar se excedente já fabricado deve ser estornado ou enviado para estoque de peças prontas.`;
} else if (componentesDivergentes.length > 0) {
acaoRecomendadaFabril = `Interromper solda/pintura: verificar adição/remoção de componentes na montagem antes de avançar.`;
} else {
acaoRecomendadaFabril = `Inspecionar peça na bancada de trabalho perante os novos dados técnicos.`;
}
} else {
nivelRisco = 'medio';
motivoAlerta = 'Alteração apenas cadastral/superficial em peça com apontamento.';
acaoRecomendadaFabril = 'Verificar etiquetas e conferir especificação na pintura.';
}
}
}
// Configura ações padrão
itensResultado.push({
key,
of_number: pecaImportada.of_number,
etapa_fase: pecaImportada.etapa_fase,
marca: pecaImportada.marca,
descricao: pecaImportada.descricao || pecaExistente.descricao || '',
isNova: false,
isIdentica,
temDivergencias,
camposDivergentes,
componentesDivergentes,
dadosImportados: pecaImportada,
dadosExistentes: pecaExistente,
pecaIdExistente: pecaId,
apontamentoResumo: {
totalProduzido: totalProduzidoMax,
processos: processosApontados,
ultimoProcessoNome: ultimoProc,
temApontamento,
emRomaneio,
},
nivelRisco,
sobrepor: temDivergencias, // Sobrepor marcado por padrão se tem divergência
atencaoEspecial: nivelRisco === 'critico', // Atenção especial marcada automaticamente se crítico
motivoAlerta,
acaoRecomendadaFabril,
});
}
// Ordenar: críticas primeiro, depois com divergência, depois novas, depois idênticas
itensResultado.sort((a, b) => {
const pesoRisco = { critico: 4, medio: 3, baixo: 2, nenhum: 1 };
const diffRisco = pesoRisco[b.nivelRisco] - pesoRisco[a.nivelRisco];
if (diffRisco !== 0) return diffRisco;
if (b.temDivergencias !== a.temDivergencias) return b.temDivergencias ? 1 : -1;
return a.marca.localeCompare(b.marca, undefined, { numeric: true });
});
return {
totalImportadas: itensResultado.length,
totalNovas: itensResultado.filter((i) => i.isNova).length,
totalIdenticas: itensResultado.filter((i) => i.isIdentica).length,
totalComDivergencia: itensResultado.filter((i) => i.temDivergencias).length,
totalComApontamento: itensResultado.filter((i) => i.apontamentoResumo.temApontamento).length,
totalCriticas: itensResultado.filter((i) => i.nivelRisco === 'critico').length,
itens: itensResultado,
};
}
+255
View File
@@ -0,0 +1,255 @@
import { supabase } from '@/integrations/supabase/client';
import { ItemAnaliseImportacao } from '@/types/importDiff';
import { extrairComprimentoSeguro } from '@/utils/importDiffService';
export interface ExecucaoImportacaoParams {
itens: ItemAnaliseImportacao[];
componentesMap: Map<string, any[]>;
userId: string;
userNome?: string;
userEmail?: string;
motivoRevisao?: string;
}
export interface ResultadoExecucaoImportacao {
sucesso: boolean;
totalInseridas: number;
totalAtualizadas: number;
totalIgnoradas: number;
totalComAtencaoEspecial: number;
itensAtencaoEspecial: ItemAnaliseImportacao[];
mensagem: string;
}
/**
* Executa a importação conciliada respeitando as decisões tomadas pelo usuário no modal.
* Preserva 100% dos IDs existentes, garantindo que nenhum apontamento de produção ou romaneio seja afetado.
*/
export async function executarImportacaoConciliada(
params: ExecucaoImportacaoParams
): Promise<ResultadoExecucaoImportacao> {
const {
itens,
componentesMap,
userId,
userNome = 'Operador',
userEmail = '',
motivoRevisao = 'Atualização de projeto via importação de lista de peças',
} = params;
console.log('🚀 Executando importação conciliada para', itens.length, 'itens analisados');
let totalInseridas = 0;
let totalAtualizadas = 0;
let totalIgnoradas = 0;
const itensAtencaoEspecial: ItemAnaliseImportacao[] = [];
try {
// 1. Separar itens por ação
const pecasNovas = itens.filter((i) => i.isNova && i.sobrepor);
const pecasParaAtualizar = itens.filter(
(i) => !i.isNova && i.sobrepor && (i.temDivergencias || i.isIdentica)
);
const pecasIgnoradas = itens.filter((i) => !i.sobrepor);
totalIgnoradas = pecasIgnoradas.length;
// 2. Atualizar peças existentes selecionadas mantendo o UUID
for (const item of pecasParaAtualizar) {
if (!item.pecaIdExistente) continue;
const pecaId = item.pecaIdExistente;
const importado = item.dadosImportados;
const existente = item.dadosExistentes || {};
// Se é idêntica e estava marcada, apenas pula a escrita desnecessária
if (item.isIdentica) {
continue;
}
// Se marcada para atenção especial, memorizar para a lista impressa
if (item.atencaoEspecial) {
itensAtencaoEspecial.push(item);
}
const qtdFinal = Number(importado.quantidade || existente.quantidade || 0);
const pesoUnitFinal = Number(importado.peso_unitario || existente.peso_unitario || 0);
const pesoTotalCalculado = Number((qtdFinal * pesoUnitFinal).toFixed(3)) || Number(importado.peso_total || 0);
const comprFinal = extrairComprimentoSeguro(importado) ?? extrairComprimentoSeguro(existente);
const payloadUpdate: any = {
descricao: importado.descricao || existente.descricao || '',
quantidade: qtdFinal,
peso_unitario: pesoUnitFinal,
peso_total: pesoTotalCalculado,
comprimento: comprFinal,
tratamento_superficial: importado.tratamento_superficial || existente.tratamento_superficial || 'pintura',
material: importado.material || existente.material || 'Aço A36',
perfil_principal: importado.perfil_principal || existente.perfil_principal || '',
tem_componentes: importado.tem_componentes !== undefined ? importado.tem_componentes : existente.tem_componentes,
updated_at: new Date().toISOString(),
};
const { error: updateErr } = await supabase
.from('pecas')
.update(payloadUpdate)
.eq('id', pecaId);
if (updateErr) {
console.error(`❌ Erro ao atualizar peça ${item.marca}:`, updateErr);
continue;
}
totalAtualizadas++;
// Atualizar componentes da peça existente
const novosComponentes = componentesMap.get(item.key) || [];
if (novosComponentes.length > 0) {
// Limpar componentes antigos e inserir os novos
await supabase.from('componentes_peca').delete().eq('peca_id', pecaId);
const componentesFormatados = novosComponentes.map((comp) => ({
...comp,
peca_id: pecaId,
user_id: userId,
}));
await supabase.from('componentes_peca').insert(componentesFormatados);
} else if (item.camposDivergentes.some((c) => c.campo === 'tem_componentes' && !c.valorNovo)) {
// Peça virou simples sem componentes: limpar antigos
await supabase.from('componentes_peca').delete().eq('peca_id', pecaId);
}
// Registrar Histórico de Revisão na tabela 'revisoes' se houve divergência
if (item.temDivergencias) {
const camposAlterados = item.camposDivergentes.map((c) => c.campo);
if (item.componentesDivergentes.length > 0) {
camposAlterados.push('componentes');
}
const proxNumeroRevisao = existente.revisao_atual
? `R${(Number(existente.revisao_atual.replace(/\D/g, '')) || 0) + 1}`
: 'R1';
try {
await supabase.from('revisoes' as any).insert({
of_number: item.of_number,
peca_id: pecaId,
numero_revisao: proxNumeroRevisao,
tipo_revisao: item.nivelRisco === 'critico' ? 'critica' : 'simples',
descricao_motivo: motivoRevisao,
observacoes: item.motivoAlerta || item.acaoRecomendadaFabril || null,
usuario_id: userId || null,
usuario_nome: userNome,
usuario_email: userEmail,
dados_anteriores: existente,
dados_novos: importado,
campos_alterados: camposAlterados,
afeta_apontamentos: item.apontamentoResumo.temApontamento,
impacto_processos: {
divergencias: item.camposDivergentes.map((c) => ({
campo: c.label,
antes: c.valorAntigo,
depois: c.valorNovo,
})),
componentes_alterados: item.componentesDivergentes,
ultimo_processo_afetado: item.apontamentoResumo.ultimoProcessoNome || null,
total_produzido: item.apontamentoResumo.totalProduzido,
atencao_especial_marcada: item.atencaoEspecial,
},
data_revisao: new Date().toISOString(),
});
} catch (revError) {
console.warn('Aviso: Não foi possível gravar histórico em revisoes:', revError);
}
}
}
// 3. Inserir peças novas
if (pecasNovas.length > 0) {
const pecasParaInsert = pecasNovas.map((p) => {
const imp = p.dadosImportados;
const qtd = Number(imp.quantidade) || 0;
const pesoU = Number(imp.peso_unitario) || 0;
const pesoT = Number(imp.peso_total) || Number((qtd * pesoU).toFixed(3));
const comprFinal = extrairComprimentoSeguro(imp);
return {
of_number: imp.of_number,
etapa_fase: imp.etapa_fase,
marca: imp.marca,
descricao: imp.descricao || imp.perfil_principal || '',
quantidade: qtd,
peso_unitario: pesoU,
peso_total: pesoT,
comprimento: comprFinal,
tratamento_superficial: imp.tratamento_superficial || 'pintura',
material: imp.material || 'Aço A36',
perfil_principal: imp.perfil_principal || '',
tem_componentes: Boolean(imp.tem_componentes),
user_id: userId,
prioridade: 'P4',
};
});
const { data: pecasInseridas, error: insertError } = await supabase
.from('pecas')
.insert(pecasParaInsert)
.select('id, of_number, etapa_fase, marca');
if (insertError) {
console.error('❌ Erro ao inserir peças novas:', insertError);
throw insertError;
}
if (pecasInseridas) {
totalInseridas = pecasInseridas.length;
// Inserir componentes das peças novas
const componentesParaInsert: any[] = [];
pecasInseridas.forEach((p) => {
const key = `${p.of_number || ''}-${p.etapa_fase || ''}-${p.marca || ''}`;
const comps = componentesMap.get(key) || [];
comps.forEach((c) => {
componentesParaInsert.push({
...c,
peca_id: p.id,
user_id: userId,
});
});
});
if (componentesParaInsert.length > 0) {
await supabase.from('componentes_peca').insert(componentesParaInsert);
}
}
}
let mensagem = `✅ Importação concluída!`;
if (totalInseridas > 0) mensagem += ` ${totalInseridas} nova(s) peça(s) inserida(s).`;
if (totalAtualizadas > 0) mensagem += ` ${totalAtualizadas} peça(s) sobreposta(s)/atualizada(s).`;
if (totalIgnoradas > 0) mensagem += ` ${totalIgnoradas} peça(s) mantida(s) no cadastro original.`;
return {
sucesso: true,
totalInseridas,
totalAtualizadas,
totalIgnoradas,
totalComAtencaoEspecial: itensAtencaoEspecial.length,
itensAtencaoEspecial,
mensagem,
};
} catch (error: any) {
console.error('❌ Falha na execução da importação conciliada:', error);
return {
sucesso: false,
totalInseridas: 0,
totalAtualizadas: 0,
totalIgnoradas: 0,
totalComAtencaoEspecial: 0,
itensAtencaoEspecial: [],
mensagem: `Erro na importação: ${error?.message || 'Falha de comunicação com o banco'}`,
};
}
}
+334
View File
@@ -0,0 +1,334 @@
import { supabase } from '@/integrations/supabase/client';
export interface ComponenteNecessario {
id: string;
quantidade_por_peca: number;
marca_componente?: string;
}
export interface ResultadoSincronizacaoPeca {
peca_id: string;
marca: string;
quantidade_total: number;
kits_completos: number;
ja_apontado_anteriormente: number;
delta_liberado: number;
}
/**
* Calcula estritamente a quantidade de kits completos cortados para uma peça mestre.
* Respeita a regra do GARGALO (bottleneck):
* Se um ou mais componentes têm corte para N peças, mas qualquer outro componente
* tem corte para apenas M peças (onde M < N), APENAS M peças são consideradas completas.
* A sobra (N - M) NÃO é liberada para Solda!
*/
export function calcularKitsCompletosPeca(
pecaQuantidade: number,
componentes: ComponenteNecessario[],
cortadoPorComponenteId: Map<string, number>
): { kitsCompletos: number; menorComponenteId?: string; razaoPorComp: Map<string, number> } {
const qtdTotal = Number(pecaQuantidade) || 0;
if (qtdTotal <= 0 || !componentes || componentes.length === 0) {
return { kitsCompletos: 0, razaoPorComp: new Map() };
}
let menorKits = qtdTotal;
let menorComponenteId: string | undefined = undefined;
const razaoPorComp = new Map<string, number>();
for (const comp of componentes) {
const cortado = cortadoPorComponenteId.get(comp.id) || 0;
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const kitsDeste = Math.floor(cortado / porPeca);
razaoPorComp.set(comp.id, kitsDeste);
if (kitsDeste < menorKits) {
menorKits = kitsDeste;
menorComponenteId = comp.id;
}
}
const kitsCompletos = Math.max(0, Math.min(qtdTotal, menorKits));
return { kitsCompletos, menorComponenteId, razaoPorComp };
}
/**
* Sincroniza uma peça específica:
* Verifica o corte de todos os seus componentes, calcula os kits 100% completos,
* e gera o apontamento da peça mestre no processo de Corte para o delta liberado.
*/
export async function sincronizarPecaKitNoCorte(params: {
ofNumber: string;
pecaId: string;
processoCorteId: string;
userId?: string;
usuarioNome?: string;
dataApontamento?: string;
}): Promise<ResultadoSincronizacaoPeca | null> {
const { ofNumber, pecaId, processoCorteId, userId, usuarioNome, dataApontamento } = params;
try {
// 1. Buscar a peça mestre
const { data: peca, error: pecaError } = await supabase
.from('pecas')
.select('id, marca, quantidade, of_number, tem_componentes')
.eq('id', pecaId)
.single();
if (pecaError || !peca) {
console.warn('Peça não encontrada para sincronização de kit:', pecaId);
return null;
}
// Se for peça S/M (sem componentes), ela não depende de kit
if (peca.tem_componentes === false) {
return null;
}
// 2. Buscar componentes cadastrados para esta peça
const { data: comps, error: compsError } = await supabase
.from('componentes_peca')
.select('id, marca_componente, quantidade_por_peca')
.eq('peca_id', pecaId);
if (compsError || !comps || comps.length === 0) {
return null;
}
// 3. Buscar todos os apontamentos de componentes já feitos para essa peça no processo de Corte
const { data: aptsComp, error: aptsCompError } = await supabase
.from('apontamentos_producao')
.select('componente_id, quantidade_produzida')
.eq('peca_id', pecaId)
.eq('processo_id', processoCorteId)
.eq('tipo_apontamento', 'componente');
if (aptsCompError) {
console.error('Erro ao buscar apontamentos de componente:', aptsCompError);
return null;
}
const cortadoPorComp = new Map<string, number>();
(aptsComp || []).forEach((a) => {
if (a.componente_id) {
cortadoPorComp.set(
a.componente_id,
(cortadoPorComp.get(a.componente_id) || 0) + Number(a.quantidade_produzida || 0)
);
}
});
// 4. Calcular kits completos estritos
const { kitsCompletos } = calcularKitsCompletosPeca(peca.quantidade, comps, cortadoPorComp);
// 5. Buscar quanto já foi apontado como peça mestre no corte
const { data: aptsPeca, error: aptsPecaError } = await supabase
.from('apontamentos_producao')
.select('quantidade_produzida')
.eq('peca_id', pecaId)
.eq('processo_id', processoCorteId)
.eq('tipo_apontamento', 'peca');
if (aptsPecaError) {
console.error('Erro ao buscar apontamentos de peça mestre:', aptsPecaError);
return null;
}
const totalJaApontadoPeca = (aptsPeca || []).reduce(
(sum, a) => sum + Number(a.quantidade_produzida || 0),
0
);
const delta = kitsCompletos - totalJaApontadoPeca;
// 6. Se houver delta positivo de kits completos formados, registrar apontamento no Corte
if (delta > 0) {
const hoje = new Date().toISOString().split('T')[0];
const payload = {
of_number: peca.of_number,
peca_id: peca.id,
processo_id: processoCorteId,
tipo_apontamento: 'peca' as const,
quantidade_produzida: delta,
data_apontamento: dataApontamento || hoje,
created_by: userId || null,
usuario_nome: usuarioNome || 'Sistema (Liberação de Kits)',
observacoes: `Liberação automática de Kit 100% cortado (${kitsCompletos}/${peca.quantidade} un)`,
status_confirmacao: 'confirmado',
is_forcado: false,
};
const { error: insertError } = await supabase.from('apontamentos_producao').insert(payload);
if (insertError) {
console.error('Erro ao inserir apontamento da peça mestre no corte:', insertError);
throw insertError;
}
console.log(`✅ Peça ${peca.marca}: +${delta} kit(s) completos liberados para Solda (${kitsCompletos}/${peca.quantidade})`);
}
return {
peca_id: peca.id,
marca: peca.marca,
quantidade_total: peca.quantidade,
kits_completos: kitsCompletos,
ja_apontado_anteriormente: totalJaApontadoPeca,
delta_liberado: Math.max(0, delta),
};
} catch (error) {
console.error('Erro ao sincronizar kits da peça no corte:', error);
return null;
}
}
/**
* Sincroniza em lote todas as peças com componentes de uma OF.
* Útil ao entrar no processo de Solda ou ao finalizar apontamentos em lote.
*/
export async function sincronizarTodosKitsOFCorte(params: {
ofNumber: string;
processoCorteId: string;
userId?: string;
usuarioNome?: string;
}): Promise<ResultadoSincronizacaoPeca[]> {
const { ofNumber, processoCorteId, userId, usuarioNome } = params;
try {
// 1. Buscar peças com componentes da OF
const { data: pecas, error: pecasError } = await supabase
.from('pecas')
.select('id, marca, quantidade, of_number, tem_componentes')
.eq('of_number', ofNumber);
if (pecasError || !pecas || pecas.length === 0) return [];
const pecasComComponentes = pecas.filter((p) => p.tem_componentes !== false);
if (pecasComComponentes.length === 0) return [];
const pecaIds = pecasComComponentes.map((p) => p.id);
// 2. Buscar componentes de todas as peças
const { data: todosComponentes, error: compsError } = await supabase
.from('componentes_peca')
.select('id, peca_id, marca_componente, quantidade_por_peca')
.in('peca_id', pecaIds);
if (compsError || !todosComponentes || todosComponentes.length === 0) return [];
const compsPorPeca = new Map<string, ComponenteNecessario[]>();
todosComponentes.forEach((c) => {
if (!compsPorPeca.has(c.peca_id)) {
compsPorPeca.set(c.peca_id, []);
}
compsPorPeca.get(c.peca_id)!.push({
id: c.id,
quantidade_por_peca: c.quantidade_por_peca,
marca_componente: c.marca_componente,
});
});
// 3. Buscar todos os apontamentos de componentes no processo de Corte
const { data: todosAptsComp, error: aptsCompError } = await supabase
.from('apontamentos_producao')
.select('peca_id, componente_id, quantidade_produzida')
.eq('of_number', ofNumber)
.eq('processo_id', processoCorteId)
.eq('tipo_apontamento', 'componente');
if (aptsCompError) {
console.error('Erro ao buscar apontamentos de componentes da OF:', aptsCompError);
return [];
}
const cortadoPorPecaEComp = new Map<string, Map<string, number>>();
(todosAptsComp || []).forEach((a) => {
if (a.peca_id && a.componente_id) {
if (!cortadoPorPecaEComp.has(a.peca_id)) {
cortadoPorPecaEComp.set(a.peca_id, new Map());
}
const mapComp = cortadoPorPecaEComp.get(a.peca_id)!;
mapComp.set(
a.componente_id,
(mapComp.get(a.componente_id) || 0) + Number(a.quantidade_produzida || 0)
);
}
});
// 4. Buscar apontamentos de peças mestres existentes no Corte
const { data: todosAptsPeca, error: aptsPecaError } = await supabase
.from('apontamentos_producao')
.select('peca_id, quantidade_produzida')
.eq('of_number', ofNumber)
.eq('processo_id', processoCorteId)
.eq('tipo_apontamento', 'peca');
if (aptsPecaError) {
console.error('Erro ao buscar apontamentos de peça da OF:', aptsPecaError);
return [];
}
const jaApontadoPorPeca = new Map<string, number>();
(todosAptsPeca || []).forEach((a) => {
if (a.peca_id) {
jaApontadoPorPeca.set(
a.peca_id,
(jaApontadoPorPeca.get(a.peca_id) || 0) + Number(a.quantidade_produzida || 0)
);
}
});
// 5. Identificar deltas e preparar inserções em lote
const hoje = new Date().toISOString().split('T')[0];
const insertsNovos: any[] = [];
const resultados: ResultadoSincronizacaoPeca[] = [];
for (const peca of pecasComComponentes) {
const comps = compsPorPeca.get(peca.id) || [];
if (comps.length === 0) continue;
const cortados = cortadoPorPecaEComp.get(peca.id) || new Map();
const { kitsCompletos } = calcularKitsCompletosPeca(peca.quantidade, comps, cortados);
const jaApontado = jaApontadoPorPeca.get(peca.id) || 0;
const delta = kitsCompletos - jaApontado;
if (delta > 0) {
insertsNovos.push({
of_number: peca.of_number,
peca_id: peca.id,
processo_id: processoCorteId,
tipo_apontamento: 'peca',
quantidade_produzida: delta,
data_apontamento: hoje,
created_by: userId || null,
usuario_nome: usuarioNome || 'Sistema (Liberação de Kits)',
observacoes: `Liberação automática de Kit 100% cortado (${kitsCompletos}/${peca.quantidade} un)`,
status_confirmacao: 'confirmado',
is_forcado: false,
});
}
resultados.push({
peca_id: peca.id,
marca: peca.marca,
quantidade_total: peca.quantidade,
kits_completos: kitsCompletos,
ja_apontado_anteriormente: jaApontado,
delta_liberado: Math.max(0, delta),
});
}
if (insertsNovos.length > 0) {
const { error: insertError } = await supabase.from('apontamentos_producao').insert(insertsNovos);
if (insertError) {
console.error('Erro ao sincronizar peças mestre em lote:', insertError);
} else {
console.log(`✅ Sincronização de kits concluída: ${insertsNovos.length} peças atualizadas no Corte.`);
}
}
return resultados;
} catch (err) {
console.error('Erro na sincronização geral de kits da OF:', err);
return [];
}
}
+247 -50
View File
@@ -1,31 +1,54 @@
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { ItemPendenciaProducao } from '@/components/expedicao/PendenciasProducaoModal'; import { ItemPendenciaProducao } from '@/components/expedicao/PendenciasProducaoModal';
export interface ProcessoFaltanteItem {
processo_id: string;
nome: string;
ordem: number;
qtdFaltante: number;
qtdFeita: number;
qtdNecessaria: number;
}
export interface PecaAnaliseRomaneio {
peca_id: string;
marca: string;
fase?: string;
quantidade_expedida: number;
tem_componentes: boolean;
processosFaltantes: ProcessoFaltanteItem[];
liberada: boolean;
qtdJaExpedida: number;
}
export interface ResultadoValidacaoEntrega { export interface ResultadoValidacaoEntrega {
valido: boolean; valido: boolean;
mensagem?: string; mensagem?: string;
pendencias?: ItemPendenciaProducao[]; pendencias?: ItemPendenciaProducao[];
pecasAnalise?: PecaAnaliseRomaneio[];
totalItens?: number;
totalLiberadas?: number;
totalPendentes?: number;
} }
/** /**
* Valida se um romaneio pode ser marcado com o status "Entregue". * Valida e analisa se um romaneio pode ser marcado com o status "Entregue".
* Verifica se todas as peças cadastradas no romaneio foram devidamente apontadas * Identifica para cada peça do romaneio se todos os processos anteriores foram concluídos,
* em todos os processos de fabricação anteriores à Expedição (Detalhamento, Corte, Solda - quando aplicável -, Pintura/Galv). * e detalha com precisão cirúrgica quais processos estão faltantes.
*/ */
export async function validarRomaneioParaEntrega( export async function validarRomaneioParaEntrega(
romaneioId?: string, romaneioId?: string,
ofNumber?: string ofNumber?: string
): Promise<ResultadoValidacaoEntrega> { ): Promise<ResultadoValidacaoEntrega> {
// Se não tem ID do romaneio (está criando um novo romaneio no momento)
if (!romaneioId) { if (!romaneioId) {
return { return {
valido: false, valido: false,
mensagem: 'Não é possível criar um novo romaneio diretamente com o status "Entregue". Crie o romaneio primeiro em planejamento, adicione as peças e garanta que todos os processos anteriores foram apontados na produção.' mensagem: 'Não é possível criar um novo romaneio diretamente com o status "Entregue". Crie o romaneio primeiro em planejamento, adicione as peças e confirme a entrega.'
}; };
} }
try { try {
// 1. Buscar as peças cadastradas no romaneio // 1. Buscar peças cadastradas no romaneio
const { data: itens, error: itensError } = await supabase const { data: itens, error: itensError } = await supabase
.from('itens_romaneio_pecas') .from('itens_romaneio_pecas')
.select('id, peca_id, marca, quantidade_expedida, fase') .select('id, peca_id, marca, quantidade_expedida, fase')
@@ -61,10 +84,10 @@ export async function validarRomaneioParaEntrega(
}; };
} }
// Encontrar o processo de Expedição (ordem 5 ou nome contendo 'expedi') // Processo de Expedição (ordem 5 ou nome contendo 'expedi')
const processoExpedicao = processos.find(p => const processoExpedicao = processos.find(p =>
p.nome?.toLowerCase().includes('expedi') || p.ordem === 5 p.nome?.toLowerCase().includes('expedi') || p.ordem === 5
) || { ordem: 5 }; ) || { id: '', nome: 'Expedicao', ordem: 5 };
// Processos anteriores que precisam estar concluídos // Processos anteriores que precisam estar concluídos
const processosAnteriores = processos.filter(p => p.ordem < processoExpedicao.ordem); const processosAnteriores = processos.filter(p => p.ordem < processoExpedicao.ordem);
@@ -73,7 +96,7 @@ export async function validarRomaneioParaEntrega(
const pecaIds = Array.from(new Set(itens.map(i => i.peca_id).filter(Boolean))); const pecaIds = Array.from(new Set(itens.map(i => i.peca_id).filter(Boolean)));
const { data: pecasInfo, error: pecasError } = await supabase const { data: pecasInfo, error: pecasError } = await supabase
.from('pecas') .from('pecas')
.select('id, marca, tem_componentes') .select('id, marca, tem_componentes, fase')
.in('id', pecaIds); .in('id', pecaIds);
if (pecasError) { if (pecasError) {
@@ -82,10 +105,10 @@ export async function validarRomaneioParaEntrega(
const pecasMap = new Map((pecasInfo || []).map(p => [p.id, p])); const pecasMap = new Map((pecasInfo || []).map(p => [p.id, p]));
// 4. Buscar apontamentos de produção existentes para essas peças // 4. Buscar apontamentos existentes para essas peças
let queryApontamentos = supabase let queryApontamentos = supabase
.from('apontamentos_producao') .from('apontamentos_producao')
.select('peca_id, processo_id, quantidade_produzida') .select('peca_id, processo_id, quantidade_produzida, tipo_apontamento')
.in('peca_id', pecaIds); .in('peca_id', pecaIds);
if (ofNumber) { if (ofNumber) {
@@ -98,10 +121,13 @@ export async function validarRomaneioParaEntrega(
console.error('❌ Erro ao buscar apontamentos:', aptError); console.error('❌ Erro ao buscar apontamentos:', aptError);
} }
// Mapear quantidades apontadas: peca_id -> processo_id -> soma(quantidade_produzida) // Mapear quantidades apontadas por peça mestre: peca_id -> processo_id -> soma(quantidade_produzida)
const apontamentosMap = new Map<string, Map<string, number>>(); const apontamentosMap = new Map<string, Map<string, number>>();
(apontamentos || []).forEach(apt => { (apontamentos || []).forEach(apt => {
// Ignorar apontamento de subcomponente para não inflar a contagem da peça mestre
if (apt.tipo_apontamento && apt.tipo_apontamento !== 'peca') return;
if (!apt.peca_id || !apt.processo_id) return; if (!apt.peca_id || !apt.processo_id) return;
if (!apontamentosMap.has(apt.peca_id)) { if (!apontamentosMap.has(apt.peca_id)) {
apontamentosMap.set(apt.peca_id, new Map<string, number>()); apontamentosMap.set(apt.peca_id, new Map<string, number>());
} }
@@ -110,58 +136,73 @@ export async function validarRomaneioParaEntrega(
procMap.set(apt.processo_id, atual + Number(apt.quantidade_produzida || 0)); procMap.set(apt.processo_id, atual + Number(apt.quantidade_produzida || 0));
}); });
// 5. Consolidar quantidade total exigida por peça no romaneio // 5. Analisar cada item do romaneio
const somaQtdRomaneioPorPeca = new Map<string, { marca: string; totalQtd: number }>();
itens.forEach(item => {
const atual = somaQtdRomaneioPorPeca.get(item.peca_id) || {
marca: item.marca,
totalQtd: 0
};
atual.totalQtd += Number(item.quantidade_expedida || 0);
somaQtdRomaneioPorPeca.set(item.peca_id, atual);
});
// 6. Verificar conformidade em todos os processos anteriores
const pendencias: ItemPendenciaProducao[] = []; const pendencias: ItemPendenciaProducao[] = [];
const pecasAnalise: PecaAnaliseRomaneio[] = [];
for (const [pecaId, { marca, totalQtd }] of somaQtdRomaneioPorPeca.entries()) { itens.forEach(item => {
const peca = pecasMap.get(pecaId); const peca = pecasMap.get(item.peca_id);
const temComponentes = peca?.tem_componentes ?? false; const temComp = peca?.tem_componentes ?? false;
const procMap = apontamentosMap.get(pecaId) || new Map<string, number>(); const procMap = apontamentosMap.get(item.peca_id) || new Map<string, number>();
const qtdRomaneio = Number(item.quantidade_expedida || 0);
for (const processo of processosAnteriores) { const processosFaltantes: ProcessoFaltanteItem[] = [];
// Se for Solda e a peça NÃO tem componentes (peça solta), ela não passa por solda
const isSolda = processo.ordem === 3 || processo.nome.toLowerCase().includes('solda'); for (const proc of processosAnteriores) {
if (isSolda && !temComponentes) { // Se for Solda e a peça NÃO tem componentes (peça simples sem montagem), não passa por solda
const isSolda = proc.ordem === 3 || proc.nome.toLowerCase().includes('solda');
if (isSolda && !temComp) {
continue; continue;
} }
const qtdApontada = procMap.get(processo.id) || 0; const qtdFeita = procMap.get(proc.id) || 0;
if (qtdFeita < qtdRomaneio) {
const qtdFaltante = Math.max(0, qtdRomaneio - qtdFeita);
processosFaltantes.push({
processo_id: proc.id,
nome: proc.nome,
ordem: proc.ordem,
qtdNecessaria: qtdRomaneio,
qtdFeita,
qtdFaltante
});
if (qtdApontada < totalQtd) {
pendencias.push({ pendencias.push({
peca_id: pecaId, peca_id: item.peca_id,
marca: marca || peca?.marca || 'Peça', marca: item.marca || peca?.marca || 'Peça',
processo: processo.nome, processo: proc.nome,
ordem: processo.ordem, ordem: proc.ordem,
qtdNecessaria: totalQtd, qtdNecessaria: qtdRomaneio,
qtdApontada, qtdApontada: qtdFeita,
qtdFaltante: Math.max(0, totalQtd - qtdApontada) qtdFaltante
}); });
} }
} }
}
if (pendencias.length > 0) { const qtdJaExpedida = procMap.get(processoExpedicao.id) || 0;
console.warn(`⚠️ Romaneio ${romaneioId} possui ${pendencias.length} pendência(s) de processos anteriores:`, pendencias);
return { pecasAnalise.push({
valido: false, peca_id: item.peca_id,
pendencias marca: item.marca || peca?.marca || 'Peça',
}; fase: item.fase || peca?.fase || '-',
} quantidade_expedida: qtdRomaneio,
tem_componentes: temComp,
processosFaltantes,
liberada: processosFaltantes.length === 0,
qtdJaExpedida
});
});
const totalLiberadas = pecasAnalise.filter(p => p.liberada).length;
const totalPendentes = pecasAnalise.filter(p => !p.liberada).length;
return { return {
valido: true valido: pendencias.length === 0,
pendencias,
pecasAnalise,
totalItens: pecasAnalise.length,
totalLiberadas,
totalPendentes
}; };
} catch (err: any) { } catch (err: any) {
console.error('❌ Exceção ao validar romaneio para entrega:', err); console.error('❌ Exceção ao validar romaneio para entrega:', err);
@@ -171,3 +212,159 @@ export async function validarRomaneioParaEntrega(
}; };
} }
} }
/**
* Executa o apontamento automático para o romaneio entregue:
* - Grava na Data Real informada pelo usuário.
* - Suporta apontamento em cascata nos processos anteriores pendentes (se autorizado pelo usuário).
* - Registra apontamento oficial no processo de Expedição.
* - Atualiza o status do romaneio para "Entregue".
*/
export async function executarApontamentoRomaneioEntregue({
romaneioId,
ofNumber,
numeroRomaneio,
dataEntregaReal,
autorizarCascata = false,
apontarApenasLiberadas = false,
userId
}: {
romaneioId: string;
ofNumber: string;
numeroRomaneio: string;
dataEntregaReal: string;
autorizarCascata?: boolean;
apontarApenasLiberadas?: boolean;
userId?: string;
}): Promise<{ sucesso: boolean; mensagem: string; totalApontamentos: number; pecasProcessadas: string[] }> {
try {
const analise = await validarRomaneioParaEntrega(romaneioId, ofNumber);
if (!analise.pecasAnalise || analise.pecasAnalise.length === 0) {
throw new Error(analise.mensagem || 'Nenhuma peça encontrada no romaneio');
}
// Buscar processo de Expedição
const { data: processos } = await supabase
.from('processos_fabricacao')
.select('id, nome, ordem')
.eq('ativo', true)
.order('ordem');
const processoExpedicao = processos?.find(p =>
p.nome?.toLowerCase().includes('expedi') || p.ordem === 5
);
if (!processoExpedicao) {
throw new Error('Processo Expedição não encontrado');
}
const apontamentosParaInserir: any[] = [];
const pecasProcessadas: string[] = [];
const obsOrigem = `Apontamento via Romaneio ${numeroRomaneio} (ID: ${romaneioId})`;
for (const peca of analise.pecasAnalise) {
// Cenário 1: Peça com processos anteriores pendentes
if (!peca.liberada) {
if (autorizarCascata) {
// Criar apontamento para cada processo faltante
for (const faltante of peca.processosFaltantes) {
apontamentosParaInserir.push({
of_number: ofNumber,
peca_id: peca.peca_id,
processo_id: faltante.processo_id,
quantidade_produzida: faltante.qtdFaltante,
data_apontamento: dataEntregaReal,
observacoes: `Apontamento em cascata (${faltante.nome}) - ${obsOrigem}`,
tipo_apontamento: 'peca',
created_by: userId
});
}
// Criar apontamento de Expedição
apontamentosParaInserir.push({
of_number: ofNumber,
peca_id: peca.peca_id,
processo_id: processoExpedicao.id,
quantidade_produzida: peca.quantidade_expedida,
data_apontamento: dataEntregaReal,
observacoes: `Apontamento de expedição - ${obsOrigem}`,
tipo_apontamento: 'peca',
created_by: userId
});
pecasProcessadas.push(`${peca.marca} (${peca.quantidade_expedida} un - com cascata)`);
} else if (apontarApenasLiberadas) {
// Pular peça pendente
continue;
} else {
// Não autorizado e não pediu para apontar parciais
continue;
}
} else {
// Cenário 2: Peça 100% liberada nos processos anteriores
apontamentosParaInserir.push({
of_number: ofNumber,
peca_id: peca.peca_id,
processo_id: processoExpedicao.id,
quantidade_produzida: peca.quantidade_expedida,
data_apontamento: dataEntregaReal,
observacoes: `Apontamento de expedição - ${obsOrigem}`,
tipo_apontamento: 'peca',
created_by: userId
});
pecasProcessadas.push(`${peca.marca} (${peca.quantidade_expedida} un)`);
}
}
if (apontamentosParaInserir.length === 0) {
return {
sucesso: false,
mensagem: 'Nenhum apontamento foi inserido pois não houve peças autorizadas para expedição.',
totalApontamentos: 0,
pecasProcessadas: []
};
}
// Inserir os apontamentos na tabela oficial
const { error: insertError } = await supabase
.from('apontamentos_producao')
.insert(apontamentosParaInserir);
if (insertError) {
console.error('❌ Erro ao inserir apontamentos:', insertError);
throw new Error(`Falha ao registrar apontamentos: ${insertError.message}`);
}
// Atualizar o status do romaneio para "Entregue"
await supabase
.from('romaneios_expedicao')
.update({
status: 'Entregue',
updated_at: new Date().toISOString()
})
.eq('id', romaneioId);
// Registrar log em logs_apontamentos_automaticos
await supabase
.from('logs_apontamentos_automaticos')
.insert({
romaneio_id: romaneioId,
numero_romaneio: numeroRomaneio,
of_number: ofNumber,
total_pecas_processadas: pecasProcessadas.length,
status_operacao: 'sucesso',
usuario_executou: userId || null
});
return {
sucesso: true,
mensagem: `${apontamentosParaInserir.length} apontamento(s) realizado(s) com sucesso na data ${dataEntregaReal}!`,
totalApontamentos: apontamentosParaInserir.length,
pecasProcessadas
};
} catch (err: any) {
console.error('❌ Erro na execução de apontamentos do romaneio:', err);
throw err;
}
}
@@ -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;
+37 -21
View File
@@ -1,46 +1,62 @@
#!/bin/bash #!/bin/bash
# --------------------------------------------------------- # ---------------------------------------------------------
# TRACKSTEEL APP: DEPLOY E SINCRONIZAÇÃO # TRACKSTEEL APP: DEPLOY & SINCRONIZAÇÃO (MARCOS-VPS -> PROD & HOSTINGER)
# --------------------------------------------------------- # ---------------------------------------------------------
set -e
CYAN='\033[0;36m' CYAN='\033[0;36m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' NC='\033[0m'
echo -e "\n${CYAN}🚀 Iniciando Ciclo Automático de Deploy TrackSteelAPP...${NC}" echo -e "\n${CYAN}🚀 Iniciando Ciclo Automático de Deploy TrackSteel APP (marcos-vps)...${NC}"
echo -e "${YELLOW}📝 Sincronizando código...${NC}" # 1. Git Commit & Push
echo -e "\n${YELLOW}📝 Sincronizando código com o repositório GitHub...${NC}"
git add . git add .
if git diff-index --quiet HEAD --; then if git diff-index --quiet HEAD --; then
echo -e "${GREEN}✨ Nenhuma nova alteração para comitar.${NC}" echo -e "${GREEN}✨ Nenhuma nova alteração para comitar.${NC}"
else else
TIMESTAMP=$(date +"%d/%m/%Y %H:%M:%S") TIMESTAMP=$(date +"%d/%m/%Y %H:%M:%S")
MSG="${1:-🚀 Auto-deploy: TrackSteelAPP atualizado em $TIMESTAMP}" MSG="${1:-🚀 Auto-deploy: TrackSteel APP atualizado em $TIMESTAMP}"
echo -e "${CYAN}📤 Criando novo commit: $MSG${NC}" echo -e "${CYAN}📤 Criando novo commit: $MSG${NC}"
git commit -m "$MSG" git commit -m "$MSG"
fi fi
echo -e "${CYAN}📤 Sincronizando com os repositórios remotos (Gitea e GitHub)...${NC}" echo -e "${CYAN}📤 Enviando alterações para o repositório (gitea & origin)...${NC}"
git push origin main git push gitea main || true
git push origin main || true
echo -e "\n${YELLOW}🔄 Disparando Deploy via API Oficial no Coolify (Hostinger)...${NC}" # 2. Build local do Frontend
echo -e "\n${YELLOW}🔨 Executando build de produção local (npm run build)...${NC}"
npm run build
# 3. Reiniciar container do Tracksteel local
echo -e "\n${YELLOW}🔄 Atualizando container local tracksteel-app...${NC}"
if docker ps -a --format '{{.Names}}' | grep -q "^tracksteel-app$"; then
docker restart tracksteel-app
echo -e "${GREEN}✅ Container tracksteel-app reiniciado com sucesso!${NC}"
else
echo -e "${YELLOW}ℹ️ Container tracksteel-app não encontrado no docker local.${NC}"
fi
# 4. Replicação Reversa de Banco de Dados para a Hostinger
echo -e "\n${YELLOW}🗄️ Executando replicação reversa de BD e Storage para a Hostinger...${NC}"
SCRIPT_SYNC="/root/Apps/TracksteelApp/scripts/sync_db_to_hostinger.sh"
if [ -f "$SCRIPT_SYNC" ]; then
chmod +x "$SCRIPT_SYNC"
"$SCRIPT_SYNC" || echo -e "${YELLOW}⚠️ Aviso: A replicação para Hostinger apresentou avisos ou falha de conexão.${NC}"
fi
# 5. Notificar Coolify da Hostinger para deploy de contingência
echo -e "\n${YELLOW}🌐 Atualizando app réplica no Coolify da Hostinger...${NC}"
COOLIFY_TOKEN="19|ZJKUbBgyeNGcKrVkMI5gJ1uMHDC39d9VIShHL473a60d9882" COOLIFY_TOKEN="19|ZJKUbBgyeNGcKrVkMI5gJ1uMHDC39d9VIShHL473a60d9882"
TRACKSTEEL_UUID="i8o44gggg00o88ccc8oo48kk" TRACKSTEEL_UUID="i8o44gggg00o88ccc8oo48kk"
curl -s -X GET "https://painel.reifonas.cloud/api/v1/deploy?uuid=${TRACKSTEEL_UUID}&force=false" \ curl -s -X GET "https://painel.reifonas.cloud/api/v1/deploy?uuid=${TRACKSTEEL_UUID}&force=false" \
-H "Authorization: Bearer $COOLIFY_TOKEN" -H "Authorization: Bearer $COOLIFY_TOKEN" >/dev/null 2>&1 || true
echo -e "\n${GREEN}🚀 Deploy local (Hostinger) engatilhado com sucesso!${NC}" echo -e "${GREEN}🚀 Deploy da réplica (Hostinger) acionado com sucesso!${NC}"
echo -e "\n${YELLOW}🌐 Sincronizando código e atualizando app na marcos-vps (100.97.2.16)...${NC}" echo -e "\n${GREEN}🏁 Ciclo de deploy e replicação concluído com sucesso!${NC}\n"
ssh -o BatchMode=yes -o ConnectTimeout=5 root@100.97.2.16 \
"cd /root/Apps/TracksteelApp && git pull origin main && npm run build && docker restart tracksteel-app" \
&& echo -e "${GREEN}✅ App na marcos-vps atualizado com sucesso!${NC}" \
|| echo -e "${YELLOW}⚠️ Aviso: Falha ao atualizar app na marcos-vps via SSH.${NC}"
echo -e "\n${YELLOW}🗄️ Replicando Banco de Dados e Storage para marcos-vps...${NC}"
if [ -f "/root/Apps/TrackSteelAPP/scripts/sync_db_to_marcos.sh" ]; then
/root/Apps/TrackSteelAPP/scripts/sync_db_to_marcos.sh
fi
echo -e "\n${GREEN}🏁 Ciclo completo de deploy e replicação concluído com sucesso!${NC}\n"