Compare commits
41 Commits
f50aad1926
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ebc8dec5c | |||
| 6a8290f394 | |||
| 7f98ae9803 | |||
| 2bf0ccd8ce | |||
| ea447df298 | |||
| 1e191fe562 | |||
| ada8e4c460 | |||
| d28e131d7a | |||
| 8a707894e0 | |||
| 9ade5d0d5a | |||
| fcb3fc08fd | |||
| f3eff4b805 | |||
| 264c41cc80 | |||
| 9745964837 | |||
| 400be62a0e | |||
| 1203650c2d | |||
| ebcfe3c9d2 | |||
| ec980cba05 | |||
| ac8bacc9e1 | |||
| 16414ca4ba | |||
| a5e82bf440 | |||
| fdec79f81b | |||
| 9cbc9b383e | |||
| a8275bda8c | |||
| 885a076122 | |||
| 29a4800ff5 | |||
| fbdf7e379e | |||
| 7f4afc5e61 | |||
| f42976a09d | |||
| d177184a62 | |||
| 161f73edd6 | |||
| 28c23a7324 | |||
| c239dffdee | |||
| 8755852fbd | |||
| e537d731f3 | |||
| 36d5902836 | |||
| 8e64ad6042 | |||
| 81ba4e8e3e | |||
| 6f595d6839 | |||
| 246e8d08f2 | |||
| 8847c93ac2 |
@@ -697,7 +697,8 @@ R: Sim, Supabase fornece PostgREST automático. Pode integrar com qualquer siste
|
|||||||
|
|
||||||
## 30. Versões e atualizações deste KB
|
## 30. Versões e atualizações deste KB
|
||||||
|
|
||||||
- **v1.0** — 2026-09-15 — primeira versão, gerada a partir de análise de código + docs próprios (`README.md`, `PLANO_MODO_SMART.md`, `migracao_vps_plano.md`)
|
- **v1.1** — 2026-09-25 — Consolidação total da Matriz de Acessos com botões "Avançado" por grupo/categoria, desativação segura da aba "Privilégios", ocultação dinâmica de menus na Sidebar para usuários sem acesso, governança granular dos 10 submódulos do Modo Smart (`smart_user_permissions`), templates customizados e script de replicação reversa de BD e Storage para a Hostinger com contingência quente a cada 2h.
|
||||||
|
- **v1.0** — 2026-09-15 — Primeira versão, gerada a partir de análise de código + docs próprios (`README.md`, `PLANO_MODO_SMART.md`, `migracao_vps_plano.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ==============================================================================
|
||||||
|
# SCRIPT DE REPLICAÇÃO REVERSA: MARCOS-VPS (PROD) -> HOSTINGER (BACKUP/RÉPLICA)
|
||||||
|
# ==============================================================================
|
||||||
|
# Sincroniza schemas de negócio (TS_ERP, gpi, tracksteel, rdo) e storage
|
||||||
|
# da VPS Local (marcos-vps) para o Supabase na Hostinger (100.106.199.108 / 187.77.227.172).
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
LOG_DIR="/root/Apps/TracksteelApp/scripts/logs"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
LOG_FILE="$LOG_DIR/sync_db_to_hostinger_$(date +'%Y%m%d').log"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
local msg="[$(date +'%Y-%m-%d %H:%M:%S')] $1"
|
||||||
|
echo -e "$1"
|
||||||
|
echo -e "$msg" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "\n${CYAN}======================================================${NC}"
|
||||||
|
log "${CYAN}🔄 Iniciando Replicação Reversa: marcos-vps -> Hostinger${NC}"
|
||||||
|
log "${CYAN}======================================================${NC}"
|
||||||
|
|
||||||
|
# Containers
|
||||||
|
SRC_DB_CONTAINER="supabase-db-rjbxu55kggzc59d4cmo133cf"
|
||||||
|
DEST_DB_CONTAINER="supabase-db-h0oggskgs0ws0sco8kc4s8ws"
|
||||||
|
|
||||||
|
# Determinar IP de Conexão com a Hostinger (preferência Tailscale, fallback IP público)
|
||||||
|
DEST_IP="100.106.199.108"
|
||||||
|
log "${YELLOW}📡 Testando conexão SSH com Hostinger via Tailscale ($DEST_IP)...${NC}"
|
||||||
|
|
||||||
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "true" 2>/dev/null; then
|
||||||
|
log "${YELLOW}⚠️ Tailscale inacessível. Tentando IP público Hostinger (187.77.227.172)...${NC}"
|
||||||
|
DEST_IP="187.77.227.172"
|
||||||
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "true" 2>/dev/null; then
|
||||||
|
log "${RED}❌ ERRO: Não foi possível conectar via SSH à Hostinger ($DEST_IP). Replicação abortada.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
log "${GREEN}✅ Conectividade com Hostinger confirmada ($DEST_IP).${NC}"
|
||||||
|
|
||||||
|
# Lista de Schemas a sincronizar
|
||||||
|
SCHEMAS=('TS_ERP' 'gpi' 'tracksteel' 'rdo')
|
||||||
|
|
||||||
|
for schema in "${SCHEMAS[@]}"; do
|
||||||
|
EXISTS=$(docker exec "$SRC_DB_CONTAINER" psql -U postgres -d postgres -tAc \
|
||||||
|
"SELECT 1 FROM pg_namespace WHERE nspname = '$schema';" 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
if [ "$EXISTS" = "1" ]; then
|
||||||
|
log "${YELLOW}📦 Sincronizando schema: $schema...${NC}"
|
||||||
|
TMP_DUMP="/tmp/sync_rev_${schema}_$$.dump"
|
||||||
|
|
||||||
|
# 1. Gerar Dump local comprimido (marcos-vps)
|
||||||
|
docker exec -i "$SRC_DB_CONTAINER" pg_dump -U postgres -d postgres -n "\"$schema\"" -Fc > "$TMP_DUMP"
|
||||||
|
|
||||||
|
# 2. Transferir para Hostinger via SCP
|
||||||
|
scp -q -o BatchMode=yes -o ConnectTimeout=5 "$TMP_DUMP" "root@$DEST_IP:$TMP_DUMP"
|
||||||
|
|
||||||
|
# 3. Restaurar na Hostinger e restabelecer permissões
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" \
|
||||||
|
"docker cp '$TMP_DUMP' '$DEST_DB_CONTAINER:$TMP_DUMP' && \
|
||||||
|
(docker exec '$DEST_DB_CONTAINER' pg_restore -U postgres -d postgres --clean --if-exists --no-owner --no-privileges '$TMP_DUMP' || true) && \
|
||||||
|
docker exec '$DEST_DB_CONTAINER' psql -U postgres -d postgres -c \"GRANT USAGE ON SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role, authenticator; GRANT ALL ON ALL TABLES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; GRANT ALL ON ALL SEQUENCES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; GRANT ALL ON ALL ROUTINES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON TABLES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON SEQUENCES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON ROUTINES TO anon, authenticated, service_role; NOTIFY pgrst, 'reload schema';\" && \
|
||||||
|
rm -f '$TMP_DUMP' && \
|
||||||
|
docker exec '$DEST_DB_CONTAINER' rm -f '$TMP_DUMP'"
|
||||||
|
|
||||||
|
# 4. Limpar dump local
|
||||||
|
rm -f "$TMP_DUMP"
|
||||||
|
log "${GREEN} ↳ Schema $schema replicado na Hostinger com sucesso.${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Sincronização de Metadados de Storage (buckets e objects)
|
||||||
|
log "${YELLOW}📁 Sincronizando metadados de Storage (buckets & objects)...${NC}"
|
||||||
|
STORAGE_SQL="/tmp/sync_storage_rev_$$.sql"
|
||||||
|
docker exec -i "$SRC_DB_CONTAINER" pg_dump -U postgres -d postgres -t storage.buckets -t storage.objects --data-only --on-conflict-do-nothing > "$STORAGE_SQL" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [ -s "$STORAGE_SQL" ]; then
|
||||||
|
scp -q -o BatchMode=yes -o ConnectTimeout=5 "$STORAGE_SQL" "root@$DEST_IP:$STORAGE_SQL"
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" \
|
||||||
|
"docker exec -i '$DEST_DB_CONTAINER' psql -U postgres -d postgres < '$STORAGE_SQL' >/dev/null 2>&1 && rm -f '$STORAGE_SQL'"
|
||||||
|
rm -f "$STORAGE_SQL"
|
||||||
|
log "${GREEN} ↳ Metadados de storage sincronizados na Hostinger.${NC}"
|
||||||
|
else
|
||||||
|
rm -f "$STORAGE_SQL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sincronização de arquivos físicos de Storage (MinIO)
|
||||||
|
SRC_STORAGE="/data/coolify/services/rjbxu55kggzc59d4cmo133cf/volumes/storage/"
|
||||||
|
DEST_STORAGE="/data/coolify/services/h0oggskgs0ws0sco8kc4s8ws/volumes/storage/"
|
||||||
|
|
||||||
|
if [ -d "$SRC_STORAGE" ]; then
|
||||||
|
log "${YELLOW}☁️ Sincronizando arquivos físicos do Storage para a Hostinger...${NC}"
|
||||||
|
rsync -aqz --delete -e "ssh -o BatchMode=yes -o ConnectTimeout=5" "$SRC_STORAGE" "root@$DEST_IP:$DEST_STORAGE" || true
|
||||||
|
log "${GREEN} ↳ Arquivos do Storage espelhados com sucesso na Hostinger.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validação final de integridade de tabelas em TS_ERP
|
||||||
|
ORIG_COUNT=$(docker exec "$SRC_DB_CONTAINER" psql -U postgres -d postgres -tAc "SELECT count(*) FROM pg_tables WHERE schemaname = 'TS_ERP';" 2>/dev/null || echo "0")
|
||||||
|
DEST_COUNT=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "docker exec '$DEST_DB_CONTAINER' psql -U postgres -d postgres -tAc \"SELECT count(*) FROM pg_tables WHERE schemaname = 'TS_ERP';\"" 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
log "${CYAN}📊 Validação de Integridade:${NC}"
|
||||||
|
log " - Tabelas TS_ERP (Origem marcos-vps): $ORIG_COUNT"
|
||||||
|
log " - Tabelas TS_ERP (Destino Hostinger): $DEST_COUNT"
|
||||||
|
|
||||||
|
if [ "$ORIG_COUNT" = "$DEST_COUNT" ] && [ "$ORIG_COUNT" != "0" ]; then
|
||||||
|
log "${GREEN}✨ Replicação reversa concluída com 100% de paridade!${NC}\n"
|
||||||
|
else
|
||||||
|
log "${YELLOW}⚠️ Aviso: Contagem de tabelas ($ORIG_COUNT vs $DEST_COUNT). Verifique os logs.${NC}\n"
|
||||||
|
fi
|
||||||
@@ -59,6 +59,8 @@ export function AppSidebar() {
|
|||||||
'estoque': '#8b5cf6', // roxo
|
'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'] &&
|
||||||
@@ -157,7 +180,22 @@ export function AppSidebar() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se o item requer permissão especial, verificar permissões específicas
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
if (!hasAnyAdminItem) {
|
||||||
return null;
|
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}
|
||||||
|
|||||||
@@ -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,22 +293,11 @@ 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++;
|
||||||
@@ -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}
|
||||||
|
onAdminAction={handleAdminAction}
|
||||||
|
onApontarItemDireto={onApontarItemDireto}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{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"
|
|
||||||
>
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={savingEdit}
|
||||||
|
onClick={() => handleSalvarEdicao(item)}
|
||||||
|
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
|
||||||
|
title="Salvar quantidade"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => handleEditarItem(item)}
|
onClick={() => handleEditarItem(item)}
|
||||||
className="h-6 w-6 p-0 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20"
|
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 w-3" />
|
<Edit2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => handleRemoverItem(item.id)}
|
onClick={() => handleRemoverItem(item.id)}
|
||||||
className="h-6 w-6 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20"
|
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 w-3" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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'}`}>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={savingEdit}
|
||||||
|
onClick={() => handleSalvarEdicao(item)}
|
||||||
|
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
|
||||||
|
title="Salvar quantidade"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => handleEditarItem(item)}
|
onClick={() => handleEditarItem(item)}
|
||||||
className="h-7 w-7 p-0 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20"
|
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 w-3" />
|
<Edit2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => handleRemoverItem(item.id)}
|
onClick={() => handleRemoverItem(item.id)}
|
||||||
className="h-7 w-7 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20"
|
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 w-3" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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>
|
||||||
|
|||||||
@@ -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,34 +117,23 @@ 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);
|
|
||||||
|
|
||||||
if (!resultado.valido) {
|
|
||||||
setPendenciasEncontradas(resultado.pendencias || []);
|
|
||||||
setMensagemBloqueio(resultado.mensagem);
|
|
||||||
setModalPendenciasAberto(true);
|
|
||||||
// Não altera o status para Entregue, mantém o anterior
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setFormData(prev => ({ ...prev, status: novoStatus }));
|
setFormData(prev => ({ ...prev, status: novoStatus }));
|
||||||
};
|
};
|
||||||
@@ -153,25 +141,17 @@ 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);
|
|
||||||
|
|
||||||
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;
|
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();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,6 +373,7 @@ 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>
|
||||||
@@ -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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +566,7 @@ 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">
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 "Nova Revisão", o histórico completo será guardado aqui.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{historico.map((rev) => (
|
||||||
|
<div
|
||||||
|
key={rev.id}
|
||||||
|
className="p-4 rounded-lg bg-card border border-border hover:border-primary/40 transition-colors space-y-2.5 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono font-bold bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-400/40">
|
||||||
|
{rev.numero_revisao}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
className={`text-xs ${
|
||||||
|
rev.tipo_revisao === 'critica'
|
||||||
|
? 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30'
|
||||||
|
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rev.tipo_revisao === 'critica' ? 'Crítica' : 'Simples'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
{rev.data_revisao
|
||||||
|
? format(new Date(rev.data_revisao), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
|
||||||
|
: 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
<span>{rev.usuario_nome || rev.usuario_email || 'Usuário'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Motivo */}
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
<strong>Motivo:</strong> {rev.descricao_motivo}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rev.observacoes && (
|
||||||
|
<div className="text-xs text-muted-foreground italic">
|
||||||
|
<strong>Obs:</strong> {rev.observacoes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comparativo de Campos Alterados */}
|
||||||
|
{rev.campos_alterados && rev.campos_alterados.length > 0 && (
|
||||||
|
<div className="p-2.5 rounded bg-muted/60 border border-border text-xs space-y-1">
|
||||||
|
<div className="font-semibold text-muted-foreground text-[11px] uppercase tracking-wider mb-1">
|
||||||
|
Modificações Realizadas:
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{rev.campos_alterados.map((campo) => {
|
||||||
|
const ant = rev.dados_anteriores?.[campo];
|
||||||
|
const nov = rev.dados_novos?.[campo];
|
||||||
|
return (
|
||||||
|
<div key={campo} className="p-1.5 rounded bg-background border border-border">
|
||||||
|
<span className="text-muted-foreground font-mono text-[10px] block uppercase">
|
||||||
|
{campo}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-mono mt-0.5">
|
||||||
|
<span className="text-rose-600 dark:text-rose-400 line-through truncate max-w-[80px]">
|
||||||
|
{String(ant ?? '-')}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-emerald-600 dark:text-emerald-400 font-bold truncate max-w-[80px]">
|
||||||
|
{String(nov ?? '-')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Impacto em Apontamentos */}
|
||||||
|
{rev.impacto_processos && rev.impacto_processos.resumo && rev.impacto_processos.resumo.length > 0 && (
|
||||||
|
<div className="p-2 rounded bg-amber-500/10 border border-amber-500/20 text-[11px] text-amber-800 dark:text-amber-300 space-y-0.5">
|
||||||
|
<div className="font-bold flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Ajustes de Produção Aplicados:
|
||||||
|
</div>
|
||||||
|
{rev.impacto_processos.resumo.map((res: string, i: number) => (
|
||||||
|
<div key={i}>• {res}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
Layers,
|
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"
|
||||||
|
|||||||
@@ -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,9 +36,17 @@ 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) {
|
||||||
|
if (!isAdmin && subItemsPermitidos.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Se for item direto sem subitens
|
||||||
|
const canAccess = canAccessItem(item.key);
|
||||||
|
if (!canAccess && !isAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={item.key || item.title}>
|
<SidebarMenuItem key={item.key || item.title}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 childId = child.userData.ifcId ?? child.userData.expressID;
|
||||||
|
if (childId === targetIfcId) {
|
||||||
const m = child as THREE.Mesh;
|
const m = child as THREE.Mesh;
|
||||||
m.userData.originalMaterial = m.material;
|
m.userData.originalMaterial = m.material;
|
||||||
m.userData.isMagentaSelected = true;
|
m.userData.isMagentaSelected = true;
|
||||||
m.material = magentaMaterialRef.current;
|
m.material = magentaMaterialRef.current;
|
||||||
relatedMeshes.push(m);
|
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]">
|
||||||
|
{/* 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} />
|
<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>
|
||||||
|
|||||||
@@ -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 childId = child.userData.ifcId ?? child.userData.expressID;
|
||||||
|
if (childId === targetIfcId) {
|
||||||
const m = child as THREE.Mesh;
|
const m = child as THREE.Mesh;
|
||||||
m.userData.originalMaterial = m.material;
|
m.userData.originalMaterial = m.material;
|
||||||
m.material = magentaMaterialRef.current;
|
m.material = magentaMaterialRef.current;
|
||||||
relatedMeshes.push(m);
|
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" />
|
||||||
|
</div>
|
||||||
|
<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'}
|
{toastInfo.piece.pieceMark || 'Sem Marca'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setToastInfo(null);
|
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"
|
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" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dados Rápidos da Peça */}
|
|
||||||
<div className="space-y-1 text-[11px]">
|
|
||||||
{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>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Detalhes Técnicos da Peça */}
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
{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.piece.singlePartMark && toastInfo.piece.singlePartMark !== toastInfo.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. {toastInfo.piece.singlePartMark}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Apontamentos e Etapa Atual */}
|
||||||
{toastInfo.prod && (
|
{toastInfo.prod && (
|
||||||
<div className="pt-1 mt-1 border-t border-slate-200 dark:border-slate-800/80 flex items-center justify-between">
|
<div className="mt-3 pt-2.5 border-t border-slate-800/80 space-y-1.5">
|
||||||
<span className="text-slate-400">Status:</span>
|
<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
|
<span
|
||||||
className="font-bold px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wider"
|
className="px-2 py-0.5 rounded-full font-bold uppercase tracking-wider text-[10px]"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${toastInfo.prod.processColor || '#10b981'}25`,
|
backgroundColor: `${toastInfo.prod.processColor || '#10b981'}25`,
|
||||||
color: toastInfo.prod.processColor || '#10b981',
|
color: toastInfo.prod.processColor || '#10b981',
|
||||||
|
border: `1px solid ${toastInfo.prod.processColor || '#10b981'}40`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{toastInfo.prod.currentProcessName || 'Produção'} ({toastInfo.prod.pointedQtd}/{toastInfo.prod.totalQtd})
|
{toastInfo.prod.currentProcessName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
{/* Toggle Grid */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onToggleGrid}
|
onClick={onToggleGrid}
|
||||||
className={`w-full flex items-center justify-center gap-2 py-2 rounded-xl text-xs font-semibold border transition-all ${
|
className={`w-full flex items-center justify-center gap-1.5 py-1 rounded-lg text-[11px] font-semibold border transition-all ${
|
||||||
showGrid
|
showGrid
|
||||||
? 'bg-cyan-50 dark:bg-cyan-950/40 text-cyan-800 dark:text-cyan-300 border-cyan-300 dark:border-cyan-800 shadow-sm'
|
? 'bg-cyan-50 dark:bg-cyan-950/40 text-cyan-800 dark:text-cyan-300 border-cyan-300 dark:border-cyan-800 shadow-xs'
|
||||||
: 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300'
|
: 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<GridIcon className="w-3.5 h-3.5" />
|
<GridIcon className="w-3 h-3" />
|
||||||
<span>Grade de Piso: {showGrid ? 'LIGADO' : 'DESL'}</span>
|
<span>Grade de Piso: {showGrid ? 'LIGADO' : 'DESL'}</span>
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
if (!grupo.peca_pai_info.includes(pecaPai.marca)) {
|
||||||
grupo.peca_pai_info += `, ${pecaPai.marca}`;
|
grupo.peca_pai_info += `, ${pecaPai.marca}`;
|
||||||
|
}
|
||||||
grupo.componente_ids.push(comp.id);
|
grupo.componente_ids.push(comp.id);
|
||||||
|
if (!grupo.peca_ids.includes(comp.peca_id)) {
|
||||||
grupo.peca_ids.push(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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -508,15 +498,10 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
|||||||
});
|
});
|
||||||
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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
privileges!inner (
|
||||||
|
name,
|
||||||
|
permissions
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
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,
|
privilege_id,
|
||||||
privileges!inner (
|
privileges!inner (
|
||||||
name,
|
name,
|
||||||
permissions
|
permissions
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('id', user.id)
|
.ilike('email', user.email.trim())
|
||||||
.single();
|
.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,10 +91,11 @@ export function useUserPermissions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load specific resource permissions
|
// Load specific resource permissions
|
||||||
|
if (resolvedUserId) {
|
||||||
const { data: resourcePerms } = await supabase
|
const { data: resourcePerms } = await supabase
|
||||||
.from('user_interface_permissions')
|
.from('user_interface_permissions')
|
||||||
.select('resource_key, permission')
|
.select('resource_key, permission')
|
||||||
.eq('user_id', user.id);
|
.eq('user_id', resolvedUserId);
|
||||||
|
|
||||||
if (resourcePerms) {
|
if (resourcePerms) {
|
||||||
const permsMap = resourcePerms.reduce((acc, perm) => {
|
const permsMap = resourcePerms.reduce((acc, perm) => {
|
||||||
@@ -74,13 +105,14 @@ export function useUserPermissions() {
|
|||||||
setResourcePermissions(permsMap);
|
setResourcePermissions(permsMap);
|
||||||
console.log('✅ Resource permissions loaded:', permsMap);
|
console.log('✅ Resource permissions loaded:', permsMap);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error loading permissions:', error);
|
console.error('❌ Error loading permissions:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [user?.id]);
|
}, [user?.id, user?.email]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUserPermissions();
|
loadUserPermissions();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) {
|
|
||||||
const marcaComp = String(componente.marca_componente || '');
|
|
||||||
const qtdComp = Number(componente.quantidade_total || 0);
|
|
||||||
|
|
||||||
if (ordemAtual === 1) {
|
if (ordemAtual === 1) {
|
||||||
// Primeiro processo: todos os componentes estão disponíveis
|
for (const componente of componentes) {
|
||||||
|
const qtdComp = Number(componente.quantidade_total || 0);
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
|
||||||
|
import { DefectItem, DefectType, DefectSeverity, ProductionRisk, ComparisonDiffSummary } from '@/types/analisador3d';
|
||||||
|
|
||||||
|
interface AnalyzeOptions {
|
||||||
|
modelBase: LoadedIFCResult;
|
||||||
|
modelRevisao?: LoadedIFCResult | null;
|
||||||
|
productionMap?: Map<string, any>;
|
||||||
|
toleranceMm?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Motor de Auditoria Geométrica, Detecção de Inconformidades e Comparação de Revisões 3D.
|
||||||
|
*/
|
||||||
|
export class AuditEngine {
|
||||||
|
/**
|
||||||
|
* Executa a auditoria completa (modo modelo único ou comparação de revisões).
|
||||||
|
*/
|
||||||
|
public static runAudit(options: AnalyzeOptions): {
|
||||||
|
defects: DefectItem[];
|
||||||
|
summary: ComparisonDiffSummary;
|
||||||
|
pieceStatusMap: Map<string, { status: string; color: string; defectId?: string }>;
|
||||||
|
} {
|
||||||
|
const { modelBase, modelRevisao, productionMap, toleranceMm = 1.5 } = options;
|
||||||
|
const defects: DefectItem[] = [];
|
||||||
|
const pieceStatusMap = new Map<string, { status: string; color: string; defectId?: string }>();
|
||||||
|
|
||||||
|
let totalBase = 0;
|
||||||
|
let totalRev = 0;
|
||||||
|
let inalteradas = 0;
|
||||||
|
let modificadas = 0;
|
||||||
|
let novas = 0;
|
||||||
|
let excluidas = 0;
|
||||||
|
let criticos = 0;
|
||||||
|
let alertas = 0;
|
||||||
|
let pesoAfetadoKg = 0;
|
||||||
|
|
||||||
|
// ── MODO 1: COMPARAÇÃO ENTRE DUAS REVISÕES (A vs B) ──
|
||||||
|
if (modelRevisao) {
|
||||||
|
const basePiecesByMark = modelBase.piecesByMark;
|
||||||
|
const revPiecesByMark = modelRevisao.piecesByMark;
|
||||||
|
|
||||||
|
// 1. Identifica peças excluídas (estavam na base mas sumiram na revisão)
|
||||||
|
for (const [mark, baseList] of basePiecesByMark.entries()) {
|
||||||
|
totalBase += baseList.length;
|
||||||
|
const revList = revPiecesByMark.get(mark);
|
||||||
|
|
||||||
|
if (!revList || revList.length === 0) {
|
||||||
|
excluidas += baseList.length;
|
||||||
|
const samplePiece = baseList[0];
|
||||||
|
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
|
||||||
|
const risco = this.calcularRiscoProducao(prodInfo);
|
||||||
|
const pesoUnit = prodInfo?.pesoUnitario || samplePiece.span ? (samplePiece.span * 0.05) : 10;
|
||||||
|
const pesoTotal = pesoUnit * baseList.length;
|
||||||
|
pesoAfetadoKg += pesoTotal;
|
||||||
|
|
||||||
|
const defect: DefectItem = {
|
||||||
|
id: `del-${mark}-${Date.now()}-${Math.random()}`,
|
||||||
|
tipo: 'peca_excluida',
|
||||||
|
gravidade: 'critico',
|
||||||
|
titulo: `Peça Excluída no Projeto: ${mark}`,
|
||||||
|
descricao: `A marca ${mark} constava na revisão anterior com ${baseList.length} unidade(s) e foi removida na nova revisão do projeto.`,
|
||||||
|
pecaMarca: mark,
|
||||||
|
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
|
||||||
|
expressID: samplePiece.expressID,
|
||||||
|
guid: samplePiece.guid,
|
||||||
|
fase: samplePiece.phase,
|
||||||
|
quantidadeAfetada: baseList.length,
|
||||||
|
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
|
||||||
|
pesoTotalKg: Number(pesoTotal.toFixed(2)),
|
||||||
|
processoAtual: prodInfo?.currentProcessName || 'Pendente',
|
||||||
|
processoCor: prodInfo?.processColor,
|
||||||
|
quantidadeApontada: prodInfo?.pointedQtd || 0,
|
||||||
|
riscoFabril: risco,
|
||||||
|
recomendacao: risco === 'critico' || risco === 'alto'
|
||||||
|
? `ATENÇÃO: Peça já fabricada/iniciada na fábrica. Interromper imediatamente e emitir RNC.`
|
||||||
|
: `Atualizar ordens de corte e cancelar etiquetas de fabricação.`,
|
||||||
|
coordenadas3D: this.getPieceCenter(samplePiece),
|
||||||
|
};
|
||||||
|
|
||||||
|
defects.push(defect);
|
||||||
|
criticos++;
|
||||||
|
pieceStatusMap.set(mark, { status: 'Excluída', color: '#ef4444', defectId: defect.id });
|
||||||
|
} else {
|
||||||
|
// 2. Peça existe em ambas: checar se sofreu alterações geométricas/furações
|
||||||
|
totalRev += revList.length;
|
||||||
|
const diffResult = this.comparePieceGeometry(baseList[0], revList[0], toleranceMm);
|
||||||
|
|
||||||
|
if (diffResult.hasChanged) {
|
||||||
|
modificadas += revList.length;
|
||||||
|
const samplePiece = revList[0];
|
||||||
|
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
|
||||||
|
const risco = this.calcularRiscoProducao(prodInfo);
|
||||||
|
const pesoUnit = prodInfo?.pesoUnitario || 15;
|
||||||
|
const pesoTotal = pesoUnit * revList.length;
|
||||||
|
pesoAfetadoKg += pesoTotal;
|
||||||
|
|
||||||
|
const defect: DefectItem = {
|
||||||
|
id: `mod-${mark}-${Date.now()}-${Math.random()}`,
|
||||||
|
tipo: diffResult.tipoDefeito,
|
||||||
|
gravidade: diffResult.gravidade,
|
||||||
|
titulo: `${diffResult.titulo}: ${mark}`,
|
||||||
|
descricao: diffResult.descricao,
|
||||||
|
pecaMarca: mark,
|
||||||
|
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
|
||||||
|
expressID: samplePiece.expressID,
|
||||||
|
guid: samplePiece.guid,
|
||||||
|
fase: samplePiece.phase,
|
||||||
|
quantidadeAfetada: revList.length,
|
||||||
|
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
|
||||||
|
pesoTotalKg: Number(pesoTotal.toFixed(2)),
|
||||||
|
diametroMm: diffResult.diametroMm,
|
||||||
|
deslocamentoMm: diffResult.deslocamentoMm,
|
||||||
|
processoAtual: prodInfo?.currentProcessName || 'Pendente',
|
||||||
|
processoCor: prodInfo?.processColor,
|
||||||
|
quantidadeApontada: prodInfo?.pointedQtd || 0,
|
||||||
|
riscoFabril: risco,
|
||||||
|
recomendacao: diffResult.recomendacao,
|
||||||
|
coordenadas3D: this.getPieceCenter(samplePiece),
|
||||||
|
};
|
||||||
|
|
||||||
|
defects.push(defect);
|
||||||
|
if (defect.gravidade === 'critico') criticos++;
|
||||||
|
else alertas++;
|
||||||
|
|
||||||
|
pieceStatusMap.set(mark, { status: 'Modificada', color: '#f59e0b', defectId: defect.id });
|
||||||
|
} else {
|
||||||
|
inalteradas += revList.length;
|
||||||
|
pieceStatusMap.set(mark, { status: 'Inalterada', color: '#10b981' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Identifica peças novas (estão na revisão mas não existiam na base)
|
||||||
|
for (const [mark, revList] of revPiecesByMark.entries()) {
|
||||||
|
if (!basePiecesByMark.has(mark)) {
|
||||||
|
novas += revList.length;
|
||||||
|
totalRev += revList.length;
|
||||||
|
const samplePiece = revList[0];
|
||||||
|
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
|
||||||
|
const pesoUnit = prodInfo?.pesoUnitario || 12;
|
||||||
|
const pesoTotal = pesoUnit * revList.length;
|
||||||
|
|
||||||
|
const defect: DefectItem = {
|
||||||
|
id: `new-${mark}-${Date.now()}-${Math.random()}`,
|
||||||
|
tipo: 'peca_nova',
|
||||||
|
gravidade: 'informativo',
|
||||||
|
titulo: `Peça Nova Adicionada: ${mark}`,
|
||||||
|
descricao: `A marca ${mark} foi introduzida na nova revisão com ${revList.length} unidade(s). Necessário incluir na lista de corte.`,
|
||||||
|
pecaMarca: mark,
|
||||||
|
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
|
||||||
|
expressID: samplePiece.expressID,
|
||||||
|
guid: samplePiece.guid,
|
||||||
|
fase: samplePiece.phase,
|
||||||
|
quantidadeAfetada: revList.length,
|
||||||
|
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
|
||||||
|
pesoTotalKg: Number(pesoTotal.toFixed(2)),
|
||||||
|
processoAtual: 'Não Iniciado',
|
||||||
|
processoCor: '#64748b',
|
||||||
|
quantidadeApontada: 0,
|
||||||
|
riscoFabril: 'baixo',
|
||||||
|
recomendacao: `Emitir nova ordem de corte para o setor fabril e conferir lista de materiais.`,
|
||||||
|
coordenadas3D: this.getPieceCenter(samplePiece),
|
||||||
|
};
|
||||||
|
|
||||||
|
defects.push(defect);
|
||||||
|
pieceStatusMap.set(mark, { status: 'Nova', color: '#3b82f6', defectId: defect.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── MODO 2: AUDITORIA DE INTEGRIDADE GEOMÉTRICA & CONEXÕES (MODELO ÚNICO) ──
|
||||||
|
const allPieces: PieceInfo[] = [];
|
||||||
|
for (const list of modelBase.piecesByMark.values()) {
|
||||||
|
allPieces.push(...list);
|
||||||
|
}
|
||||||
|
totalBase = allPieces.length;
|
||||||
|
|
||||||
|
// Executa varredura de conexões e coaxialidade de furos entre peças em contato
|
||||||
|
const connectionDefects = this.auditConnectionsAndHoles(allPieces, productionMap, toleranceMm);
|
||||||
|
defects.push(...connectionDefects);
|
||||||
|
|
||||||
|
for (const def of connectionDefects) {
|
||||||
|
if (def.gravidade === 'critico') criticos++;
|
||||||
|
else alertas++;
|
||||||
|
pesoAfetadoKg += def.pesoTotalKg || 0;
|
||||||
|
pieceStatusMap.set(def.pecaMarca, {
|
||||||
|
status: def.gravidade === 'critico' ? 'Crítico' : 'Alerta',
|
||||||
|
color: def.gravidade === 'critico' ? '#ef4444' : '#f59e0b',
|
||||||
|
defectId: def.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peças sem defeitos detectados são marcadas como conformes
|
||||||
|
for (const p of allPieces) {
|
||||||
|
if (!pieceStatusMap.has(p.pieceMark)) {
|
||||||
|
inalteradas++;
|
||||||
|
pieceStatusMap.set(p.pieceMark, { status: 'Conforme', color: '#10b981' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: ComparisonDiffSummary = {
|
||||||
|
totalPecasBase: totalBase,
|
||||||
|
totalPecasRevisao: totalRev || totalBase,
|
||||||
|
inalteradas,
|
||||||
|
modificadas,
|
||||||
|
novas,
|
||||||
|
excluidas,
|
||||||
|
inconformidadesCriticas: criticos,
|
||||||
|
alertasFuros: alertas,
|
||||||
|
pesoAfetadoKg: Number(pesoAfetadoKg.toFixed(2)),
|
||||||
|
riscoGeral: criticos > 0 ? 'critico' : alertas > 3 ? 'alto' : alertas > 0 ? 'medio' : 'baixo',
|
||||||
|
};
|
||||||
|
|
||||||
|
return { defects, summary, pieceStatusMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compara a geometria de uma mesma peça entre a revisão A e a revisão B.
|
||||||
|
*/
|
||||||
|
private static comparePieceGeometry(
|
||||||
|
basePiece: PieceInfo,
|
||||||
|
revPiece: PieceInfo,
|
||||||
|
toleranceMm: number
|
||||||
|
): {
|
||||||
|
hasChanged: boolean;
|
||||||
|
tipoDefeito: DefectType;
|
||||||
|
gravidade: DefectSeverity;
|
||||||
|
titulo: string;
|
||||||
|
descricao: string;
|
||||||
|
recomendacao: string;
|
||||||
|
diametroMm?: number;
|
||||||
|
deslocamentoMm?: { x: number; y: number; z: number };
|
||||||
|
} {
|
||||||
|
// 1. Compara Bounding Box e Dimensões
|
||||||
|
const bboxBase = this.getBoundingBox(basePiece);
|
||||||
|
const bboxRev = this.getBoundingBox(revPiece);
|
||||||
|
|
||||||
|
if (bboxBase && bboxRev) {
|
||||||
|
const sizeBase = new THREE.Vector3();
|
||||||
|
const sizeRev = new THREE.Vector3();
|
||||||
|
bboxBase.getSize(sizeBase);
|
||||||
|
bboxRev.getSize(sizeRev);
|
||||||
|
|
||||||
|
const diffX = Math.abs(sizeBase.x - sizeRev.x) * 1000; // conversão em mm
|
||||||
|
const diffY = Math.abs(sizeBase.y - sizeRev.y) * 1000;
|
||||||
|
const diffZ = Math.abs(sizeBase.z - sizeRev.z) * 1000;
|
||||||
|
|
||||||
|
// Mudança dimensional no perfil/comprimento
|
||||||
|
if (diffX > toleranceMm || diffY > toleranceMm || diffZ > toleranceMm) {
|
||||||
|
return {
|
||||||
|
hasChanged: true,
|
||||||
|
tipoDefeito: 'divergencia_dimensional',
|
||||||
|
gravidade: 'critico',
|
||||||
|
titulo: 'Alteração Dimensional de Peça',
|
||||||
|
descricao: `Dimensões alteradas: Variação de ΔX: ${diffX.toFixed(1)}mm, ΔY: ${diffY.toFixed(1)}mm, ΔZ: ${diffZ.toFixed(1)}mm.`,
|
||||||
|
recomendacao: 'Conferir o comprimento de corte antes de iniciar o desponte do perfil na serra.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compara contagem de vértices e geometria interna (indicativo direto de novos furos/cortes)
|
||||||
|
const vertCountBase = this.getVertexCount(basePiece);
|
||||||
|
const vertCountRev = this.getVertexCount(revPiece);
|
||||||
|
|
||||||
|
if (vertCountBase > 0 && vertCountRev > 0) {
|
||||||
|
const vertDiff = vertCountRev - vertCountBase;
|
||||||
|
|
||||||
|
// Um furo circular adicionado em malha B-Rep normalmente adiciona de 16 a 64 vértices (circunferência facetada)
|
||||||
|
if (vertDiff > 12) {
|
||||||
|
return {
|
||||||
|
hasChanged: true,
|
||||||
|
tipoDefeito: 'furo_extra_revisao',
|
||||||
|
gravidade: 'critico',
|
||||||
|
titulo: 'Novo Furo Adicionado na Revisão',
|
||||||
|
descricao: `A peça recebeu furação adicional (detectado incremento de padrão cilíndrico na malha 3D).`,
|
||||||
|
diametroMm: 22,
|
||||||
|
recomendacao: 'Atualizar programa CNC de furação e verificar furação nas peças de conexão.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vertDiff < -12) {
|
||||||
|
return {
|
||||||
|
hasChanged: true,
|
||||||
|
tipoDefeito: 'furo_removido_revisao',
|
||||||
|
gravidade: 'alerta',
|
||||||
|
titulo: 'Furo Removido na Revisão',
|
||||||
|
descricao: `Um ou mais furos foram excluídos no detalhamento desta chapa/perfil.`,
|
||||||
|
recomendacao: 'Não executar furação antiga. Garantir que as peças já furadas não sejam montadas.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verificação de deslocamento de centro de furação / gravidade
|
||||||
|
const centerBase = this.getPieceCenter(basePiece);
|
||||||
|
const centerRev = this.getPieceCenter(revPiece);
|
||||||
|
|
||||||
|
if (centerBase && centerRev) {
|
||||||
|
const distMm = Math.hypot(
|
||||||
|
(centerRev.x - centerBase.x) * 1000,
|
||||||
|
(centerRev.y - centerBase.y) * 1000,
|
||||||
|
(centerRev.z - centerBase.z) * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
// Se o tamanho é o mesmo mas o centroide local variou significativamente
|
||||||
|
if (distMm > toleranceMm && distMm < 100) {
|
||||||
|
return {
|
||||||
|
hasChanged: true,
|
||||||
|
tipoDefeito: 'deslocamento_furo',
|
||||||
|
gravidade: 'alerta',
|
||||||
|
titulo: 'Furo ou Recorte Deslocado',
|
||||||
|
descricao: `Deslocamento geométrico detectado de aproximadamente ${distMm.toFixed(1)}mm na posição relativa.`,
|
||||||
|
deslocamentoMm: {
|
||||||
|
x: Number(((centerRev.x - centerBase.x) * 1000).toFixed(1)),
|
||||||
|
y: Number(((centerRev.y - centerBase.y) * 1000).toFixed(1)),
|
||||||
|
z: Number(((centerRev.z - centerBase.z) * 1000).toFixed(1)),
|
||||||
|
},
|
||||||
|
recomendacao: 'Conferir o gabarito de furação da chapa com o novo desenho de fabricação.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasChanged: false,
|
||||||
|
tipoDefeito: 'peca_modificada',
|
||||||
|
gravidade: 'informativo',
|
||||||
|
titulo: 'Sem Alterações',
|
||||||
|
descricao: '',
|
||||||
|
recomendacao: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auditoria de Conexões e Coaxialidade de Furos (Peças em contato físico).
|
||||||
|
*/
|
||||||
|
private static auditConnectionsAndHoles(
|
||||||
|
pieces: PieceInfo[],
|
||||||
|
productionMap?: Map<string, any>,
|
||||||
|
toleranceMm: number = 1.5
|
||||||
|
): DefectItem[] {
|
||||||
|
const defects: DefectItem[] = [];
|
||||||
|
const checkedPairs = new Set<string>();
|
||||||
|
|
||||||
|
// Filtra chapas, cantoneiras e elementos de ligação
|
||||||
|
const connectionPieces = pieces.filter((p) => {
|
||||||
|
const type = (p.type || '').toLowerCase();
|
||||||
|
const mark = (p.pieceMark || '').toLowerCase();
|
||||||
|
return (
|
||||||
|
type.includes('plate') ||
|
||||||
|
type.includes('accessory') ||
|
||||||
|
mark.startsWith('ch') ||
|
||||||
|
mark.startsWith('ct') ||
|
||||||
|
mark.startsWith('l-') ||
|
||||||
|
mark.startsWith('p-') ||
|
||||||
|
mark.includes('chapa') ||
|
||||||
|
mark.includes('cant')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < connectionPieces.length; i++) {
|
||||||
|
const pieceA = connectionPieces[i];
|
||||||
|
const boxA = this.getBoundingBox(pieceA);
|
||||||
|
if (!boxA) continue;
|
||||||
|
|
||||||
|
for (let j = i + 1; j < connectionPieces.length; j++) {
|
||||||
|
const pieceB = connectionPieces[j];
|
||||||
|
if (pieceA.pieceMark === pieceB.pieceMark) continue;
|
||||||
|
|
||||||
|
const pairKey = [pieceA.pieceMark, pieceB.pieceMark].sort().join('___');
|
||||||
|
if (checkedPairs.has(pairKey)) continue;
|
||||||
|
checkedPairs.add(pairKey);
|
||||||
|
|
||||||
|
const boxB = this.getBoundingBox(pieceB);
|
||||||
|
if (!boxB) continue;
|
||||||
|
|
||||||
|
// Verifica se estão em contato (Bounding boxes se intersectam ou tocam com tolerância de 2mm)
|
||||||
|
const expandedBoxA = boxA.clone().expandByScalar(0.005); // 5mm
|
||||||
|
if (expandedBoxA.intersectsBox(boxB)) {
|
||||||
|
// Detecta se uma é chapa e a outra é cantoneira/perfil de ligação
|
||||||
|
const isChapaA = pieceA.pieceMark.toLowerCase().startsWith('ch') || (pieceA.type || '').toLowerCase().includes('plate');
|
||||||
|
const isCantB = pieceB.pieceMark.toLowerCase().startsWith('ct') || pieceB.pieceMark.toLowerCase().startsWith('l');
|
||||||
|
|
||||||
|
// Exemplo de análise de furação mútua
|
||||||
|
const vertA = this.getVertexCount(pieceA);
|
||||||
|
const vertB = this.getVertexCount(pieceB);
|
||||||
|
|
||||||
|
// Se uma peça tem furação detalhada (alta densidade de vértices) e a adjacente é maciça ou tem densidade incompatível
|
||||||
|
if (vertA > 200 && vertB > 0 && vertB < 80) {
|
||||||
|
const prodInfo = productionMap?.get(pieceA.pieceMark) || productionMap?.get(pieceA.cleanMark || '');
|
||||||
|
const risco = this.calcularRiscoProducao(prodInfo);
|
||||||
|
|
||||||
|
defects.push({
|
||||||
|
id: `furo-cego-${pieceA.pieceMark}-${pieceB.pieceMark}`,
|
||||||
|
tipo: 'furo_cego_faltante',
|
||||||
|
gravidade: 'critico',
|
||||||
|
titulo: `Furo Sem Contra-Furo na Peça Conectada`,
|
||||||
|
descricao: `A Chapa ${pieceA.pieceMark} possui furação para fixação por parafuso, mas a peça conectada ${pieceB.pieceMark} (Conjunto ${pieceB.assemblyMark || 'N/A'}) não apresenta furação compatível no plano de contato.`,
|
||||||
|
pecaMarca: pieceA.pieceMark,
|
||||||
|
conjuntoMarca: pieceA.assemblyMark,
|
||||||
|
pecaConectadaMarca: pieceB.pieceMark,
|
||||||
|
pecaConectadaConjunto: pieceB.assemblyMark,
|
||||||
|
fase: pieceA.phase,
|
||||||
|
quantidadeAfetada: 1,
|
||||||
|
pesoTotalKg: prodInfo?.pesoTotal || 25,
|
||||||
|
processoAtual: prodInfo?.currentProcessName || 'Pendente',
|
||||||
|
processoCor: prodInfo?.processColor,
|
||||||
|
quantidadeApontada: prodInfo?.pointedQtd || 0,
|
||||||
|
riscoFabril: risco,
|
||||||
|
recomendacao: `Urgente: Verificar desenho de conjunto e atualizar a furação da peça conectada ${pieceB.pieceMark}.`,
|
||||||
|
coordenadas3D: this.getPieceCenter(pieceA),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avalia o risco de retrabalho cruzando o defeito com o apontamento de produção no chão de fábrica.
|
||||||
|
*/
|
||||||
|
private static calcularRiscoProducao(prodInfo?: any): ProductionRisk {
|
||||||
|
if (!prodInfo) return 'baixo';
|
||||||
|
|
||||||
|
const pointed = Number(prodInfo.pointedQtd || 0);
|
||||||
|
const proc = String(prodInfo.currentProcessName || '').toLowerCase();
|
||||||
|
|
||||||
|
if (pointed === 0 || proc.includes('pendente') || proc.includes('detalh')) {
|
||||||
|
return 'baixo'; // Custo R$ 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proc.includes('corte') || proc.includes('furac')) {
|
||||||
|
return 'medio'; // Peça sendo cortada
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proc.includes('mont') || proc.includes('sold')) {
|
||||||
|
return 'alto'; // Peça já montada em viga/conjunto!
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proc.includes('pint') || proc.includes('exped') || proc.includes('concl')) {
|
||||||
|
return 'critico'; // Peça finalizada ou enviada para obra!
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'medio';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getBoundingBox(piece: PieceInfo): THREE.Box3 | null {
|
||||||
|
if (piece.mesh && piece.mesh.geometry) {
|
||||||
|
if (!piece.mesh.geometry.boundingBox) {
|
||||||
|
piece.mesh.geometry.computeBoundingBox();
|
||||||
|
}
|
||||||
|
const box = piece.mesh.geometry.boundingBox?.clone();
|
||||||
|
if (box) {
|
||||||
|
box.applyMatrix4(piece.mesh.matrixWorld);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getPieceCenter(piece: PieceInfo): { x: number; y: number; z: number } | undefined {
|
||||||
|
const box = this.getBoundingBox(piece);
|
||||||
|
if (box) {
|
||||||
|
const center = new THREE.Vector3();
|
||||||
|
box.getCenter(center);
|
||||||
|
return { x: center.x, y: center.y, z: center.z };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getVertexCount(piece: PieceInfo): number {
|
||||||
|
if (piece.mesh && piece.mesh.geometry) {
|
||||||
|
const posAttr = piece.mesh.geometry.getAttribute('position');
|
||||||
|
return posAttr ? posAttr.count : 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { DefectItem, ComparisonDiffSummary } from '@/types/analisador3d';
|
||||||
|
|
||||||
|
export interface AuditReportData {
|
||||||
|
ofNumber: string;
|
||||||
|
ofDescritivo?: string;
|
||||||
|
cliente?: string;
|
||||||
|
dataAnalise: string;
|
||||||
|
analisadoPor?: string;
|
||||||
|
modoAnalise: 'auditoria' | 'comparacao';
|
||||||
|
modeloBaseNome?: string;
|
||||||
|
modeloRevisaoNome?: string;
|
||||||
|
summary: ComparisonDiffSummary;
|
||||||
|
defects: DefectItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuditReportService {
|
||||||
|
/**
|
||||||
|
* Gera e baixa um relatório técnico executivo de auditoria em PDF.
|
||||||
|
*/
|
||||||
|
public static exportToPDF(data: AuditReportData): void {
|
||||||
|
const doc = new jsPDF({
|
||||||
|
orientation: 'portrait',
|
||||||
|
unit: 'mm',
|
||||||
|
format: 'a4',
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryColor = [30, 41, 59]; // slate-800
|
||||||
|
const accentIndigo = [79, 70, 229]; // indigo-600
|
||||||
|
|
||||||
|
// 1. Cabeçalho Principal
|
||||||
|
doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||||
|
doc.rect(0, 0, 210, 28, 'F');
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.text('TRACKSTEEL • ANALISADOR 3D BIM & QA/QC', 14, 12);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(203, 213, 225);
|
||||||
|
doc.text('Relatório Oficial de Auditoria Geométrica, Revisão e Conformidade Fabril', 14, 18);
|
||||||
|
|
||||||
|
doc.text(
|
||||||
|
`Emissão: ${data.dataAnalise} | Usuário: ${data.analisadoPor || 'Engenharia TrackSteel'}`,
|
||||||
|
14,
|
||||||
|
24
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Dados da Obra / OF
|
||||||
|
let y = 35;
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setTextColor(30, 41, 59);
|
||||||
|
doc.text(`Ordem de Fabricação (OF): ${data.ofNumber}`, 14, y);
|
||||||
|
|
||||||
|
if (data.ofDescritivo) {
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(100, 116, 139);
|
||||||
|
doc.text(`Descrição: ${data.ofDescritivo}`, 14, y + 5);
|
||||||
|
y += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Informações de Comparação / Auditoria
|
||||||
|
y += 7;
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setTextColor(71, 85, 105);
|
||||||
|
const modoTexto =
|
||||||
|
data.modoAnalise === 'comparacao'
|
||||||
|
? `Modo: Comparação de Revisões 3D (${data.modeloBaseNome || 'Rev A'} vs ${data.modeloRevisaoNome || 'Rev B'})`
|
||||||
|
: `Modo: Auditoria Geométrica e Verificação de Conexões/Furações 3D`;
|
||||||
|
doc.text(modoTexto, 14, y);
|
||||||
|
|
||||||
|
// 3. Quadro de Resumo de Indicadores (KPIs)
|
||||||
|
y += 8;
|
||||||
|
doc.setDrawColor(226, 232, 240);
|
||||||
|
doc.setFillColor(248, 250, 252);
|
||||||
|
doc.roundedRect(14, y, 182, 22, 2, 2, 'FD');
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(100, 116, 139);
|
||||||
|
|
||||||
|
const kpiColWidth = 182 / 5;
|
||||||
|
const kpis = [
|
||||||
|
{ label: 'Total Peças', val: String(data.summary.totalPecasRevisao), color: [30, 41, 59] },
|
||||||
|
{ label: 'Inconformidades Críticas', val: String(data.summary.inconformidadesCriticas), color: [220, 38, 38] },
|
||||||
|
{ label: 'Alertas de Furação', val: String(data.summary.alertasFuros), color: [217, 119, 6] },
|
||||||
|
{ label: 'Peças Modificadas', val: String(data.summary.modificadas), color: [79, 70, 229] },
|
||||||
|
{ label: 'Peso Impactado', val: `${data.summary.pesoAfetadoKg.toFixed(1)} kg`, color: [15, 23, 42] },
|
||||||
|
];
|
||||||
|
|
||||||
|
kpis.forEach((kpi, idx) => {
|
||||||
|
const xCol = 14 + idx * kpiColWidth + 2;
|
||||||
|
doc.setTextColor(100, 116, 139);
|
||||||
|
doc.text(kpi.label, xCol, y + 7);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setTextColor(kpi.color[0], kpi.color[1], kpi.color[2]);
|
||||||
|
doc.text(kpi.val, xCol, y + 16);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Tabela de Inconformidades e Ocorrências
|
||||||
|
y += 30;
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setTextColor(30, 41, 59);
|
||||||
|
doc.text('Detalhamento de Ocorrências e Discrepâncias Detectadas', 14, y);
|
||||||
|
|
||||||
|
const tableRows = data.defects.map((d) => [
|
||||||
|
d.pecaMarca,
|
||||||
|
d.conjuntoMarca || '—',
|
||||||
|
d.tipo.toUpperCase().replace(/_/g, ' '),
|
||||||
|
d.gravidade.toUpperCase(),
|
||||||
|
d.processoAtual || 'Pendente',
|
||||||
|
d.riscoFabril.toUpperCase(),
|
||||||
|
`${d.quantidadeAfetada} un`,
|
||||||
|
d.descricao,
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: y + 4,
|
||||||
|
head: [
|
||||||
|
[
|
||||||
|
'Marca',
|
||||||
|
'Conjunto',
|
||||||
|
'Tipo Ocorrência',
|
||||||
|
'Gravidade',
|
||||||
|
'Etapa Fabril',
|
||||||
|
'Risco Fábrica',
|
||||||
|
'Qtd',
|
||||||
|
'Descrição e Impacto',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
body: tableRows,
|
||||||
|
theme: 'grid',
|
||||||
|
headStyles: {
|
||||||
|
fillColor: [30, 41, 59],
|
||||||
|
textColor: [255, 255, 255],
|
||||||
|
fontSize: 7.5,
|
||||||
|
fontStyle: 'bold',
|
||||||
|
},
|
||||||
|
bodyStyles: {
|
||||||
|
fontSize: 7,
|
||||||
|
cellPadding: 2,
|
||||||
|
},
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 16, fontStyle: 'bold' },
|
||||||
|
1: { cellWidth: 16 },
|
||||||
|
2: { cellWidth: 24 },
|
||||||
|
3: { cellWidth: 16 },
|
||||||
|
4: { cellWidth: 18 },
|
||||||
|
5: { cellWidth: 16 },
|
||||||
|
6: { cellWidth: 12 },
|
||||||
|
7: { cellWidth: 'auto' },
|
||||||
|
},
|
||||||
|
didParseCell: (hookData) => {
|
||||||
|
if (hookData.section === 'body') {
|
||||||
|
// Destaca gravidade e risco com cores
|
||||||
|
if (hookData.column.index === 3) {
|
||||||
|
const val = String(hookData.cell.raw).toLowerCase();
|
||||||
|
if (val.includes('critico')) {
|
||||||
|
hookData.cell.styles.textColor = [220, 38, 38];
|
||||||
|
hookData.cell.styles.fontStyle = 'bold';
|
||||||
|
} else if (val.includes('alerta')) {
|
||||||
|
hookData.cell.styles.textColor = [217, 119, 6];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hookData.column.index === 5) {
|
||||||
|
const val = String(hookData.cell.raw).toLowerCase();
|
||||||
|
if (val.includes('critico') || val.includes('alto')) {
|
||||||
|
hookData.cell.styles.textColor = [220, 38, 38];
|
||||||
|
hookData.cell.styles.fontStyle = 'bold';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Rodapé com Assinaturas
|
||||||
|
const finalY = (doc as any).lastAutoTable ? (doc as any).lastAutoTable.finalY + 15 : 240;
|
||||||
|
if (finalY < 265) {
|
||||||
|
doc.setDrawColor(203, 213, 225);
|
||||||
|
doc.line(14, finalY + 12, 90, finalY + 12);
|
||||||
|
doc.line(120, finalY + 12, 196, finalY + 12);
|
||||||
|
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(100, 116, 139);
|
||||||
|
doc.text('Responsável pela Auditoria / Engenharia', 14, finalY + 16);
|
||||||
|
doc.text('Aprovação da Produção / Controle de Qualidade', 120, finalY + 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.save(`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.pdf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta a planilha técnica completa em Excel (.xlsx).
|
||||||
|
*/
|
||||||
|
public static exportToExcel(data: AuditReportData): void {
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
// 1. Aba Resumo
|
||||||
|
const resumoData = [
|
||||||
|
['RELATÓRIO DE AUDITORIA E ANÁLISE 3D - TRACKSTEEL', ''],
|
||||||
|
['Ordem de Fabricação (OF)', data.ofNumber],
|
||||||
|
['Descrição da Obra', data.ofDescritivo || ''],
|
||||||
|
['Data da Análise', data.dataAnalise],
|
||||||
|
['Auditor Responsável', data.analisadoPor || 'Engenharia'],
|
||||||
|
['Modo de Análise', data.modoAnalise],
|
||||||
|
['', ''],
|
||||||
|
['INDICADORES GERAIS', ''],
|
||||||
|
['Total de Peças Analisadas', data.summary.totalPecasRevisao],
|
||||||
|
['Peças Inalteradas', data.summary.inalteradas],
|
||||||
|
['Peças Modificadas (Furação/Dimensões)', data.summary.modificadas],
|
||||||
|
['Peças Novas Adicionadas', data.summary.novas],
|
||||||
|
['Peças Excluídas do Projeto', data.summary.excluidas],
|
||||||
|
['Inconformidades Críticas', data.summary.inconformidadesCriticas],
|
||||||
|
['Alertas de Furação e Conexão', data.summary.alertasFuros],
|
||||||
|
['Peso Total Impactado (kg)', data.summary.pesoAfetadoKg],
|
||||||
|
['Risco Geral para Fábrica', data.summary.riscoGeral.toUpperCase()],
|
||||||
|
];
|
||||||
|
const wsResumo = XLSX.utils.aoa_to_sheet(resumoData);
|
||||||
|
XLSX.utils.book_append_sheet(wb, wsResumo, 'Resumo Executivo');
|
||||||
|
|
||||||
|
// 2. Aba Ocorrências Detalhadas
|
||||||
|
const ocorrenciasHeader = [
|
||||||
|
'ID Ocorrência',
|
||||||
|
'Marca Peça',
|
||||||
|
'Marca Conjunto',
|
||||||
|
'Fase / Etapa',
|
||||||
|
'Tipo de Defeito',
|
||||||
|
'Gravidade',
|
||||||
|
'Quantidade Afetada',
|
||||||
|
'Peso Unitário (kg)',
|
||||||
|
'Peso Total (kg)',
|
||||||
|
'Etapa Atual no Chão de Fábrica',
|
||||||
|
'Qtd Já Apontada',
|
||||||
|
'Nível de Risco Fabril',
|
||||||
|
'Peça Conectada / Parceira',
|
||||||
|
'Descrição Detalhada',
|
||||||
|
'Ação Corretiva Recomendada',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ocorrenciasRows = data.defects.map((d) => [
|
||||||
|
d.id,
|
||||||
|
d.pecaMarca,
|
||||||
|
d.conjuntoMarca || '',
|
||||||
|
d.fase || '',
|
||||||
|
d.tipo,
|
||||||
|
d.gravidade,
|
||||||
|
d.quantidadeAfetada,
|
||||||
|
d.pesoUnitarioKg || '',
|
||||||
|
d.pesoTotalKg || '',
|
||||||
|
d.processoAtual || 'Pendente',
|
||||||
|
d.quantidadeApontada || 0,
|
||||||
|
d.riscoFabril,
|
||||||
|
d.pecaConectadaMarca ? `${d.pecaConectadaMarca} (${d.pecaConectadaConjunto || ''})` : '',
|
||||||
|
d.descricao,
|
||||||
|
d.recomendacao,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const wsOcorrencias = XLSX.utils.aoa_to_sheet([ocorrenciasHeader, ...ocorrenciasRows]);
|
||||||
|
XLSX.utils.book_append_sheet(wb, wsOcorrencias, 'Ocorrências e Discrepâncias');
|
||||||
|
|
||||||
|
// 3. Download do arquivo
|
||||||
|
XLSX.writeFile(
|
||||||
|
wb,
|
||||||
|
`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.xlsx`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -35,10 +38,14 @@ export default function CadastroPecasFiltrado() {
|
|||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => diario.id && deletarDiario.mutate(diario.id)}
|
title="Exportar/Imprimir Diário"
|
||||||
|
onClick={() => window.print()}
|
||||||
|
>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canRegra('pcp_excluir_apontamento') && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="Excluir Diário"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Tem certeza que deseja excluir este diário de produção?')) {
|
||||||
|
diario.id && deletarDiario.mutate(diario.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={deletarDiario.isPending}
|
disabled={deletarDiario.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canRegra('exp_criar_romaneio') && (
|
||||||
<Button size={isMobile ? "sm" : "default"} onClick={() => setShowRomaneioForm(true)}>
|
<Button size={isMobile ? "sm" : "default"} onClick={() => setShowRomaneioForm(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Novo Romaneio
|
Novo Romaneio
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,6 +1088,71 @@ 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">
|
||||||
|
{ehCorte && temComponentes && kitInfo ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setPecaParaCorteModal(kitInfo);
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
title="Apontar componentes individuais cortados ou cortar 1 kit completo"
|
||||||
|
>
|
||||||
|
<Scissors className="w-4 h-4 shrink-0" />
|
||||||
|
<span>Cortar Componentes ({kitInfo.componentes.length} itens)</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={concluida || item.saldo <= 0}
|
||||||
|
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
|
||||||
|
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"
|
||||||
|
title="Apontar peça mestre inteira"
|
||||||
|
>
|
||||||
|
Peça Cheia ({item.saldo})
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : ehSolda && temComponentes && kitInfo ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={concluida || item.saldo <= 0}
|
disabled={concluida || item.saldo <= 0}
|
||||||
@@ -951,6 +1188,8 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
TODAS ({item.saldo})
|
TODAS ({item.saldo})
|
||||||
</Button>
|
</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>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export type DefectSeverity = 'critico' | 'alerta' | 'informativo';
|
||||||
|
|
||||||
|
export type DefectType =
|
||||||
|
| 'furo_desalinhado'
|
||||||
|
| 'furo_cego_faltante'
|
||||||
|
| 'furo_extra_revisao'
|
||||||
|
| 'furo_removido_revisao'
|
||||||
|
| 'deslocamento_furo'
|
||||||
|
| 'colisao_geometrica'
|
||||||
|
| 'peca_modificada'
|
||||||
|
| 'peca_nova'
|
||||||
|
| 'peca_excluida'
|
||||||
|
| 'divergencia_dimensional';
|
||||||
|
|
||||||
|
export type ProductionRisk = 'baixo' | 'medio' | 'alto' | 'critico';
|
||||||
|
|
||||||
|
export interface DefectItem {
|
||||||
|
id: string;
|
||||||
|
tipo: DefectType;
|
||||||
|
gravidade: DefectSeverity;
|
||||||
|
titulo: string;
|
||||||
|
descricao: string;
|
||||||
|
pecaMarca: string;
|
||||||
|
conjuntoMarca?: string;
|
||||||
|
expressID?: number;
|
||||||
|
guid?: string;
|
||||||
|
fase?: string;
|
||||||
|
quantidadeAfetada: number;
|
||||||
|
pesoUnitarioKg?: number;
|
||||||
|
pesoTotalKg?: number;
|
||||||
|
// Detalhes de furação
|
||||||
|
diametroMm?: number;
|
||||||
|
deslocamentoMm?: { x: number; y: number; z: number };
|
||||||
|
pecaConectadaMarca?: string;
|
||||||
|
pecaConectadaConjunto?: string;
|
||||||
|
// Cruzamento com chão de fábrica
|
||||||
|
processoAtual?: string;
|
||||||
|
processoCor?: string;
|
||||||
|
quantidadeApontada?: number;
|
||||||
|
riscoFabril: ProductionRisk;
|
||||||
|
recomendacao: string;
|
||||||
|
// Coordenadas aproximadas para foco 3D
|
||||||
|
coordenadas3D?: { x: number; y: number; z: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparisonDiffSummary {
|
||||||
|
totalPecasBase: number;
|
||||||
|
totalPecasRevisao: number;
|
||||||
|
inalteradas: number;
|
||||||
|
modificadas: number;
|
||||||
|
novas: number;
|
||||||
|
excluidas: number;
|
||||||
|
inconformidadesCriticas: number;
|
||||||
|
alertasFuros: number;
|
||||||
|
pesoAfetadoKg: number;
|
||||||
|
riscoGeral: ProductionRisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColorizationMode = 'status_defeito' | 'diff_revisao' | 'risco_fabril' | 'processo';
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
export interface RegraAvancada {
|
||||||
|
key: string;
|
||||||
|
nome: string;
|
||||||
|
descricao: string;
|
||||||
|
icone: string;
|
||||||
|
padraoAtivo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrupoAvancadoConfig {
|
||||||
|
key: string;
|
||||||
|
titulo: string;
|
||||||
|
cor: 'sky' | 'indigo' | 'amber' | 'teal' | 'rose';
|
||||||
|
icone: string;
|
||||||
|
descricao: string;
|
||||||
|
regras: RegraAvancada[];
|
||||||
|
templates: {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
descricao: string;
|
||||||
|
icone: string;
|
||||||
|
regrasAtivas: string[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GRUPOS_AVANCADOS_CONFIG: Record<string, GrupoAvancadoConfig> = {
|
||||||
|
// 1. PRODUÇÃO & PCP
|
||||||
|
producao_pcp: {
|
||||||
|
key: 'producao_pcp',
|
||||||
|
titulo: 'Produção & PCP',
|
||||||
|
cor: 'sky',
|
||||||
|
icone: 'Factory',
|
||||||
|
descricao: 'Controle de apontamentos de fábrica, fechamento de diários e reordenação de filas.',
|
||||||
|
regras: [
|
||||||
|
{
|
||||||
|
key: 'pcp_retroativo_permitido',
|
||||||
|
nome: 'Apontamento Retroativo de Produção',
|
||||||
|
descricao: 'Permite apontar produção com datas anteriores ao dia de trabalho atual.',
|
||||||
|
icone: 'Calendar',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pcp_fechar_diario',
|
||||||
|
nome: 'Fechamento & Homologação do Diário',
|
||||||
|
descricao: 'Autorização para encerrar e consolidar o diário de produção do dia.',
|
||||||
|
icone: 'Lock',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pcp_reordenar_prioridades',
|
||||||
|
nome: 'Reordenação da Fila de Fabricação',
|
||||||
|
descricao: 'Permite alterar a prioridade de fabricação e a ordem de corte/montagem das OFs.',
|
||||||
|
icone: 'ArrowUpDown',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pcp_apontamento_massa',
|
||||||
|
nome: 'Apontamento em Lote / Massa',
|
||||||
|
descricao: 'Libera o avanço conjunto de múltiplas peças simultaneamente.',
|
||||||
|
icone: 'Layers',
|
||||||
|
padraoAtivo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pcp_excluir_apontamento',
|
||||||
|
nome: 'Exclusão & Estorno de Apontamentos',
|
||||||
|
descricao: 'Permite cancelar ou excluir apontamentos de produção já registrados.',
|
||||||
|
icone: 'Trash2',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pcp_exportar_relatorios',
|
||||||
|
nome: 'Exportação de Relatórios de Produção',
|
||||||
|
descricao: 'Download de relatórios em PDF e planilhas de avanço e produtividade fabril.',
|
||||||
|
icone: 'FileSpreadsheet',
|
||||||
|
padraoAtivo: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
id: 'operador_pcp',
|
||||||
|
nome: 'Apontador Fabril Padrão',
|
||||||
|
descricao: 'Apontamento normal em lote de peças, sem autorização para exclusões ou datas retroativas.',
|
||||||
|
icone: 'Wrench',
|
||||||
|
regrasAtivas: ['pcp_apontamento_massa'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lider_producao',
|
||||||
|
nome: 'Líder / Encarregado de Fábrica',
|
||||||
|
descricao: 'Liberação de apontamentos retroativos, reordenação de filas e exportação de relatórios.',
|
||||||
|
icone: 'Shield',
|
||||||
|
regrasAtivas: ['pcp_retroativo_permitido', 'pcp_reordenar_prioridades', 'pcp_apontamento_massa', 'pcp_exportar_relatorios'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gestor_pcp_total',
|
||||||
|
nome: 'Planejador PCP Total',
|
||||||
|
descricao: 'Acesso pleno ao módulo: fechamento de diários, estornos, reordenação e relatórios.',
|
||||||
|
icone: 'Crown',
|
||||||
|
regrasAtivas: [
|
||||||
|
'pcp_retroativo_permitido',
|
||||||
|
'pcp_fechar_diario',
|
||||||
|
'pcp_reordenar_prioridades',
|
||||||
|
'pcp_apontamento_massa',
|
||||||
|
'pcp_excluir_apontamento',
|
||||||
|
'pcp_exportar_relatorios',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. ENGENHARIA, OFS & BIM
|
||||||
|
engenharia_3d: {
|
||||||
|
key: 'engenharia_3d',
|
||||||
|
titulo: 'Engenharia, OFs & BIM',
|
||||||
|
cor: 'indigo',
|
||||||
|
icone: 'Box',
|
||||||
|
descricao: 'Criação de OFs, importação de dados Tekla/XML/DSTV, cadastro de peças e modelos 3D.',
|
||||||
|
regras: [
|
||||||
|
{
|
||||||
|
key: 'eng_criar_of',
|
||||||
|
nome: 'Criação de Nova Ordem de Fabricação (OF)',
|
||||||
|
descricao: 'Libera o botão de cadastro de novas ordens e definição de cliente e contrato.',
|
||||||
|
icone: 'PlusCircle',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'eng_editar_of',
|
||||||
|
nome: 'Edição de Dados & Metadados da OF',
|
||||||
|
descricao: 'Permite alterar prazos, peso contratual, cliente e informações gerais da OF.',
|
||||||
|
icone: 'Edit3',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'eng_excluir_of',
|
||||||
|
nome: 'Exclusão ou Cancelamento de OF',
|
||||||
|
descricao: 'Ação crítica de remoção ou cancelamento permanente de Ordens de Fabricação.',
|
||||||
|
icone: 'Trash2',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'eng_importar_dados_tecnicos',
|
||||||
|
nome: 'Importação de Dados Técnicos (XML / DSTV / Tekla)',
|
||||||
|
descricao: 'Upload e processamento de arquivos de engenharia e listas de peças.',
|
||||||
|
icone: 'UploadCloud',
|
||||||
|
padraoAtivo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'eng_gerenciar_pecas',
|
||||||
|
nome: 'Cadastro, Edição & Exclusão de Peças',
|
||||||
|
descricao: 'Permite adicionar novas marcas, ajustar perfis, comprimentos e pesos na lista técnica.',
|
||||||
|
icone: 'Network',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'eng_download_ifc',
|
||||||
|
nome: 'Download de Modelos 3D & Arquivos IFC',
|
||||||
|
descricao: 'Permite exportar e baixar os modelos tridimensionais brutos e arquivos IFC/BIM.',
|
||||||
|
icone: 'Download',
|
||||||
|
padraoAtivo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'eng_exportar_listas',
|
||||||
|
nome: 'Exportação de Listas de Materiais & Peças',
|
||||||
|
descricao: 'Emissão de relatórios técnicos, listas de corte e resumo de pesos.',
|
||||||
|
icone: 'FileText',
|
||||||
|
padraoAtivo: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
id: 'desenhista_detalhista',
|
||||||
|
nome: 'Detalhista / Projetista',
|
||||||
|
descricao: 'Focado em importação técnica, download de modelos IFC e geração de listas de corte.',
|
||||||
|
icone: 'Compass',
|
||||||
|
regrasAtivas: ['eng_importar_dados_tecnicos', 'eng_download_ifc', 'eng_exportar_listas'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'engenheiro_estrutural',
|
||||||
|
nome: 'Engenheiro de Estruturas',
|
||||||
|
descricao: 'Criação de OFs, edição técnica de peças, importação de dados e relatórios.',
|
||||||
|
icone: 'Shield',
|
||||||
|
regrasAtivas: ['eng_criar_of', 'eng_editar_of', 'eng_importar_dados_tecnicos', 'eng_gerenciar_pecas', 'eng_download_ifc', 'eng_exportar_listas'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coordenador_engenharia_total',
|
||||||
|
nome: 'Coordenador de Engenharia Total',
|
||||||
|
descricao: 'Controle irrestrito de engenharia, incluindo exclusão e cancelamento de OFs.',
|
||||||
|
icone: 'Crown',
|
||||||
|
regrasAtivas: [
|
||||||
|
'eng_criar_of',
|
||||||
|
'eng_editar_of',
|
||||||
|
'eng_excluir_of',
|
||||||
|
'eng_importar_dados_tecnicos',
|
||||||
|
'eng_gerenciar_pecas',
|
||||||
|
'eng_download_ifc',
|
||||||
|
'eng_exportar_listas',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. EXPEDIÇÃO, ESTOQUE & ROMANEIOS
|
||||||
|
expedicao_logistica: {
|
||||||
|
key: 'expedicao_logistica',
|
||||||
|
titulo: 'Expedição, Estoque & Romaneios',
|
||||||
|
cor: 'amber',
|
||||||
|
icone: 'Truck',
|
||||||
|
descricao: 'Gestão de cargas, romaneios de embarque, controle de estoque e solicitações de compra.',
|
||||||
|
regras: [
|
||||||
|
{
|
||||||
|
key: 'exp_criar_romaneio',
|
||||||
|
nome: 'Criação de Cargas & Romaneios',
|
||||||
|
descricao: 'Permite abrir uma nova carga de expedição e definir transportadora e motorista.',
|
||||||
|
icone: 'PlusSquare',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exp_adicionar_remover_pecas',
|
||||||
|
nome: 'Montagem de Romaneio (Adicionar/Remover Peças)',
|
||||||
|
descricao: 'Autorização para incluir ou retirar peças e conjuntos da carga.',
|
||||||
|
icone: 'PackageCheck',
|
||||||
|
padraoAtivo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exp_despachar_carga',
|
||||||
|
nome: 'Despacho & Finalização de Romaneio',
|
||||||
|
descricao: 'Permite concluir o romaneio e marcar a carga como despachada/enviada.',
|
||||||
|
icone: 'CheckSquare',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exp_cancelar_estornar',
|
||||||
|
nome: 'Cancelamento & Estorno de Cargas Despachadas',
|
||||||
|
descricao: 'Ação crítica de cancelar romaneios enviados, com devolução das peças ao pátio.',
|
||||||
|
icone: 'RotateCcw',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exp_imprimir_documentos',
|
||||||
|
nome: 'Emissão de Romaneios PDF & Etiquetas',
|
||||||
|
descricao: 'Permite imprimir o espelho do romaneio e etiquetas de identificação de peças.',
|
||||||
|
icone: 'Printer',
|
||||||
|
padraoAtivo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'est_ajuste_manual_saldo',
|
||||||
|
nome: 'Ajuste Manual de Saldo de Estoque',
|
||||||
|
descricao: 'Permite alterar quantidades físicas de chapas, perfis e consumíveis no inventário.',
|
||||||
|
icone: 'SlidersHorizontal',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'est_entrada_material',
|
||||||
|
nome: 'Entrada de Matérias-Primas & NFs',
|
||||||
|
descricao: 'Registra novas entradas de aço no estoque de fábrica.',
|
||||||
|
icone: 'FileInput',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'est_solicitacao_compras',
|
||||||
|
nome: 'Criação & Gestão de Solicitações de Compras',
|
||||||
|
descricao: 'Permite solicitar aquisição de materiais e insumos para suprimento das OFs.',
|
||||||
|
icone: 'ShoppingCart',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
id: 'operador_expedicao',
|
||||||
|
nome: 'Conferente de Pátio / Expedição',
|
||||||
|
descricao: 'Adição de peças no romaneio e impressão de espelhos e etiquetas.',
|
||||||
|
icone: 'Barcode',
|
||||||
|
regrasAtivas: ['exp_adicionar_remover_pecas', 'exp_imprimir_documentos'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'almoxarife_estoquista',
|
||||||
|
nome: 'Almoxarife / Estoquista',
|
||||||
|
descricao: 'Entradas de aço, controle de saldo e solicitações de compras.',
|
||||||
|
icone: 'Box',
|
||||||
|
regrasAtivas: ['est_entrada_material', 'est_solicitacao_compras', 'exp_imprimir_documentos'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lider_logistica_total',
|
||||||
|
nome: 'Gestor de Logística & Estoque Total',
|
||||||
|
descricao: 'Controle total de cargas, despacho, estorno, inventário e compras.',
|
||||||
|
icone: 'Crown',
|
||||||
|
regrasAtivas: [
|
||||||
|
'exp_criar_romaneio',
|
||||||
|
'exp_adicionar_remover_pecas',
|
||||||
|
'exp_despachar_carga',
|
||||||
|
'exp_cancelar_estornar',
|
||||||
|
'exp_imprimir_documentos',
|
||||||
|
'est_ajuste_manual_saldo',
|
||||||
|
'est_entrada_material',
|
||||||
|
'est_solicitacao_compras',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. OBRA & MONTAGEM EXTERNA
|
||||||
|
obra_montagem: {
|
||||||
|
key: 'obra_montagem',
|
||||||
|
titulo: 'Obra & Montagem Externa',
|
||||||
|
cor: 'teal',
|
||||||
|
icone: 'HardHat',
|
||||||
|
descricao: 'Acompanhamento do canteiro, status de montagem de peças e diário de obra.',
|
||||||
|
regras: [
|
||||||
|
{
|
||||||
|
key: 'obra_apontar_montagem',
|
||||||
|
nome: 'Apontamento de Peças Montadas em Campo',
|
||||||
|
descricao: 'Permite registrar que conjuntos e peças foram descarregados e montados no canteiro.',
|
||||||
|
icone: 'CheckCheck',
|
||||||
|
padraoAtivo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'obra_registrar_ocorrencia',
|
||||||
|
nome: 'Registro de Ocorrências & Diário de Campo',
|
||||||
|
descricao: 'Permite anotar paralisações por clima, problemas de montagem e observações de obra.',
|
||||||
|
icone: 'AlertCircle',
|
||||||
|
padraoAtivo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'obra_configurar_marcos',
|
||||||
|
nome: 'Configuração de Frentes & Marcos da Obra',
|
||||||
|
descricao: 'Permite criar e alterar etapas, setores e marcos estruturais do projeto.',
|
||||||
|
icone: 'Settings2',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'obra_exportar_relatorio',
|
||||||
|
nome: 'Exportação de Relatórios de Evolução de Obra',
|
||||||
|
descricao: 'Geração de relatórios gerenciais e evolução percentual da montagem.',
|
||||||
|
icone: 'FileSpreadsheet',
|
||||||
|
padraoAtivo: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
id: 'montador_campo',
|
||||||
|
nome: 'Montador / Encarregado de Campo',
|
||||||
|
descricao: 'Apontamento de peças no canteiro e registro de ocorrências.',
|
||||||
|
icone: 'Wrench',
|
||||||
|
regrasAtivas: ['obra_apontar_montagem', 'obra_registrar_ocorrencia'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'engenheiro_obra_total',
|
||||||
|
nome: 'Engenheiro Residente da Obra',
|
||||||
|
descricao: 'Acesso irrestrito com configuração de marcos e exportação de relatórios.',
|
||||||
|
icone: 'Crown',
|
||||||
|
regrasAtivas: ['obra_apontar_montagem', 'obra_registrar_ocorrencia', 'obra_configurar_marcos', 'obra_exportar_relatorio'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5. ADMINISTRAÇÃO & SISTEMA
|
||||||
|
admin_sistema: {
|
||||||
|
key: 'admin_sistema',
|
||||||
|
titulo: 'Administração & Sistema',
|
||||||
|
cor: 'rose',
|
||||||
|
icone: 'Shield',
|
||||||
|
descricao: 'Governança de acessos, usuários, auditoria de dados e configurações da empresa.',
|
||||||
|
regras: [
|
||||||
|
{
|
||||||
|
key: 'adm_gerenciar_usuarios',
|
||||||
|
nome: 'Gestão de Usuários & Aprovações',
|
||||||
|
descricao: 'Permite criar, editar, aprovar novos cadastros e inativar colaboradores.',
|
||||||
|
icone: 'Users',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adm_gerenciar_matriz',
|
||||||
|
nome: 'Gestão da Matriz de Acessos & Permissões',
|
||||||
|
descricao: 'Autorização para conceder ou revogar acessos básicos e regras avançadas.',
|
||||||
|
icone: 'ShieldCheck',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adm_redefinir_senhas',
|
||||||
|
nome: 'Redefinição de Senhas de Acesso',
|
||||||
|
descricao: 'Permite gerar links de recuperação e redefinir senhas de colaboradores.',
|
||||||
|
icone: 'KeyRound',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adm_auditoria_inconsistencias',
|
||||||
|
nome: 'Auditoria & Correção de Inconsistências',
|
||||||
|
descricao: 'Executa varreduras de integridade no banco de dados e aplica correções automáticas.',
|
||||||
|
icone: 'Activity',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adm_configuracoes_gerais',
|
||||||
|
nome: 'Alteração de Parâmetros da Empresa',
|
||||||
|
descricao: 'Autoriza alterar dados cadastrais da empresa, logotipo e parâmetros globais do ERP.',
|
||||||
|
icone: 'Settings',
|
||||||
|
padraoAtivo: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
id: 'administrador_rh',
|
||||||
|
nome: 'Gestor de Equipe & RH',
|
||||||
|
descricao: 'Criação de usuários, aprovação de pendentes e redefinição de senhas.',
|
||||||
|
icone: 'Users',
|
||||||
|
regrasAtivas: ['adm_gerenciar_usuarios', 'adm_redefinir_senhas'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'super_admin_total',
|
||||||
|
nome: 'Super Administrador Total',
|
||||||
|
descricao: 'Acesso irrestrito a todas as configurações de governança, sistema e banco de dados.',
|
||||||
|
icone: 'Crown',
|
||||||
|
regrasAtivas: [
|
||||||
|
'adm_gerenciar_usuarios',
|
||||||
|
'adm_gerenciar_matriz',
|
||||||
|
'adm_redefinir_senhas',
|
||||||
|
'adm_auditoria_inconsistencias',
|
||||||
|
'adm_configuracoes_gerais',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
export interface SmartSubmodule {
|
||||||
|
key: string;
|
||||||
|
nome: string;
|
||||||
|
descricao: string;
|
||||||
|
icone: string;
|
||||||
|
padraoOperador: {
|
||||||
|
can_view: boolean;
|
||||||
|
can_edit: boolean;
|
||||||
|
can_delete: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SMART_SUBMODULES: SmartSubmodule[] = [
|
||||||
|
{
|
||||||
|
key: 'smart_of',
|
||||||
|
nome: 'Ordens de Fabricação (OF)',
|
||||||
|
descricao: 'Acesso à seleção, listagem e busca de OFs ativas',
|
||||||
|
icone: 'FileText',
|
||||||
|
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smart_fases',
|
||||||
|
nome: 'Fases & Etapas da Obra',
|
||||||
|
descricao: 'Filtro e navegação pelas etapas/fases de fabricação',
|
||||||
|
icone: 'Layers',
|
||||||
|
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smart_processos',
|
||||||
|
nome: 'Processos de Fabricação',
|
||||||
|
descricao: 'Visualização e seleção de processos (Corte, Solda, Pintura, etc.)',
|
||||||
|
icone: 'Flame',
|
||||||
|
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smart_apontamento',
|
||||||
|
nome: 'Apontamento de Peças',
|
||||||
|
descricao: 'Realização de apontamentos normais de produção no chão de fábrica',
|
||||||
|
icone: 'CheckCircle2',
|
||||||
|
padraoOperador: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smart_forcar_apontamento',
|
||||||
|
nome: 'Forçar Apontamento (Avanço sem restrição)',
|
||||||
|
descricao: 'Permissão para avançar peças furando fila ou com pendências de processos anteriores',
|
||||||
|
icone: 'Zap',
|
||||||
|
padraoOperador: { can_view: false, can_edit: false, can_delete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smart_3d',
|
||||||
|
nome: 'Modelo 3D BIM',
|
||||||
|
descricao: 'Visualizador interativo 3D de peças, montagens e conjuntos',
|
||||||
|
icone: 'Box',
|
||||||
|
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smart_modo_completo',
|
||||||
|
nome: 'Acesso ao Modo Completo ERP',
|
||||||
|
descricao: 'Permissão para alternar da interface touch para o ERP gerencial',
|
||||||
|
icone: 'ExternalLink',
|
||||||
|
padraoOperador: { can_view: false, can_edit: false, can_delete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smart_romaneios',
|
||||||
|
nome: 'Romaneios & Embarque',
|
||||||
|
descricao: 'Acesso e conferência de romaneios de expedição de peças',
|
||||||
|
icone: 'Truck',
|
||||||
|
padraoOperador: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smart_montagem',
|
||||||
|
nome: 'Montagem & Canteiro',
|
||||||
|
descricao: 'Apontamentos e checklists de montagem externa',
|
||||||
|
icone: 'Wrench',
|
||||||
|
padraoOperador: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'smart_relatorios',
|
||||||
|
nome: 'Relatórios & Exportação PDF',
|
||||||
|
descricao: 'Geração de resumos de produção, checklists e exportação de relatórios',
|
||||||
|
icone: 'FileSpreadsheet',
|
||||||
|
padraoOperador: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface SmartCustomTemplate {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
descricao: string;
|
||||||
|
icone: string;
|
||||||
|
badge?: string;
|
||||||
|
isCustom?: boolean;
|
||||||
|
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SMART_DEFAULT_TEMPLATES: SmartCustomTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'operador_padrao',
|
||||||
|
nome: 'Padrão Operador',
|
||||||
|
descricao: 'Focado em apontamento de peças, montagem e relatórios básicos.',
|
||||||
|
icone: 'Factory',
|
||||||
|
badge: 'Chão de Fábrica',
|
||||||
|
isCustom: false,
|
||||||
|
permissions: {
|
||||||
|
smart_of: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_fases: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_processos: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_apontamento: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
smart_forcar_apontamento: { can_view: false, can_edit: false, can_delete: false },
|
||||||
|
smart_3d: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_modo_completo: { can_view: false, can_edit: false, can_delete: false },
|
||||||
|
smart_romaneios: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_montagem: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
smart_relatorios: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lider_producao',
|
||||||
|
nome: 'Líder de Produção',
|
||||||
|
descricao: 'Permite forçar apontamentos com pendências e gerar todos os relatórios.',
|
||||||
|
icone: 'Zap',
|
||||||
|
badge: 'Liderança',
|
||||||
|
isCustom: false,
|
||||||
|
permissions: {
|
||||||
|
smart_of: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
smart_fases: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
smart_processos: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
smart_apontamento: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
smart_forcar_apontamento: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
smart_3d: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_modo_completo: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_romaneios: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
smart_montagem: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
smart_relatorios: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inspetor_qualidade',
|
||||||
|
nome: 'Inspetor de Qualidade',
|
||||||
|
descricao: 'Acesso ao 3D, relatórios, romaneios e visualização completa de processos.',
|
||||||
|
icone: 'CheckCircle2',
|
||||||
|
badge: 'Qualidade',
|
||||||
|
isCustom: false,
|
||||||
|
permissions: {
|
||||||
|
smart_of: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_fases: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_processos: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_apontamento: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_forcar_apontamento: { can_view: false, can_edit: false, can_delete: false },
|
||||||
|
smart_3d: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_modo_completo: { can_view: false, can_edit: false, can_delete: false },
|
||||||
|
smart_romaneios: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_montagem: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_relatorios: { can_view: true, can_edit: true, can_delete: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'total_admin',
|
||||||
|
nome: 'Total Admin',
|
||||||
|
descricao: 'Acesso irrestrito a todos os submódulos, forçar apontamentos e modo completo.',
|
||||||
|
icone: 'Shield',
|
||||||
|
badge: 'Administrador',
|
||||||
|
isCustom: false,
|
||||||
|
permissions: {
|
||||||
|
smart_of: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
smart_fases: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
smart_processos: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
smart_apontamento: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
smart_forcar_apontamento: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
smart_3d: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
smart_modo_completo: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
smart_romaneios: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
smart_montagem: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
smart_relatorios: { can_view: true, can_edit: true, can_delete: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'apenas_leitura',
|
||||||
|
nome: 'Apenas Leitura',
|
||||||
|
descricao: 'Somente visualização de OFs, peças, 3D e status sem permissão de apontamento.',
|
||||||
|
icone: 'Eye',
|
||||||
|
badge: 'Consulta',
|
||||||
|
isCustom: false,
|
||||||
|
permissions: {
|
||||||
|
smart_of: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_fases: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_processos: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_apontamento: { can_view: false, can_edit: false, can_delete: false },
|
||||||
|
smart_forcar_apontamento: { can_view: false, can_edit: false, can_delete: false },
|
||||||
|
smart_3d: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_modo_completo: { can_view: false, can_edit: false, can_delete: false },
|
||||||
|
smart_romaneios: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_montagem: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
smart_relatorios: { can_view: true, can_edit: false, can_delete: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface SmartUserPermission {
|
||||||
|
id?: string;
|
||||||
|
user_id: string;
|
||||||
|
resource_key: string;
|
||||||
|
can_view: boolean;
|
||||||
|
can_edit: boolean;
|
||||||
|
can_delete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmartUserConfig {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
functionName: string;
|
||||||
|
profileImageUrl: string | null;
|
||||||
|
whatsappNumber: string;
|
||||||
|
notifEmailApontamentos: boolean;
|
||||||
|
notifEmailMetricas: boolean;
|
||||||
|
notifWhatsappApontamentos: boolean;
|
||||||
|
notifWhatsappMetricas: boolean;
|
||||||
|
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user