Compare commits

...

99 Commits

Author SHA1 Message Date
Marcos cc4ac64f55 style: otimizacao de layout do relatorio para a4 retrato mais compacto e profissional 2026-09-02 21:09:42 +00:00
Marcos 4fe116a9c0 fix: removido kg do interior da barra, correcao de foreign keys no relatorio de pecas e multiplos botoes fechar na modal 2026-09-02 19:40:42 +00:00
Marcos cf0c6cd657 style: mostrar os valores exatos em kg ao invés de toneladas no grafico de visao geral 2026-09-02 19:19:42 +00:00
Marcos d5fe88520b style: melhoria de UI e cores dinamicas no grafico de apontamento diario 2026-09-02 19:07:42 +00:00
Marcos 5d3879c0da fix: calculo de metricas dos ultimos apontamentos com correcao nas horas e foreign keys 2026-09-02 18:42:42 +00:00
Marcos 5316fc9551 style: melhoria na responsividade e visualização do gráfico de evolução quinzenal 2026-09-02 18:04:46 +00:00
Marcos 1578fb23e2 fix: corrigido graficos de metas da quinzena no painel industrial não aparecendo dados quando histórico estava vazio 2026-09-02 16:39:42 +00:00
Marcos 9fb29d55a8 feat: controle manual de revisao no Kanban de prioridades ao gerar relatorios 2026-09-02 15:03:08 +00:00
Marcos b201e67291 fix: conversão de tipo no PostgreSQL e correção de erro 400 Bad Request ao importar peças em lote via XLS 2026-09-02 14:15:55 +00:00
Marcos 78d3f72545 style: otimizar cores do PDF do cronograma para impressão monocromática 2026-09-02 13:52:57 +00:00
Marcos 8413cf64a9 chore: drop legacy auth.users fks blocking Logto users 2026-09-02 13:44:06 +00:00
Marcos f27610df97 fix(apontamentos): previne erro 409 por trigger de concorrência e melhora tratamento de erro 2026-08-31 21:00:13 +00:00
Marcos 8107e46eb1 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 21:03:35 2026-08-21 21:03:35 +00:00
Marcos 9109c4ca04 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 20:59:11 2026-08-21 20:59:11 +00:00
Marcos 35e9387be3 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 20:54:10 2026-08-21 20:54:10 +00:00
Marcos ed60dcb12c 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 19:29:38 2026-08-21 19:29:38 +00:00
Marcos 01f509d88b 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 19:24:52 2026-08-21 19:24:52 +00:00
Marcos 1616baaafc 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 19:16:41 2026-08-21 19:16:41 +00:00
Marcos 3cdbd3d747 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 19:04:13 2026-08-21 19:04:13 +00:00
Marcos 79c4c6c460 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 18:49:15 2026-08-21 18:49:15 +00:00
Marcos fa3408c238 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 18:42:14 2026-08-21 18:42:14 +00:00
Marcos dccb9acdce 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 18:35:52 2026-08-21 18:35:52 +00:00
Marcos 8478eecb93 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 18:30:30 2026-08-21 18:30:30 +00:00
Marcos 22892a4374 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 18:08:42 2026-08-21 18:08:42 +00:00
Marcos d57c94b9a5 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 18:00:59 2026-08-21 18:00:59 +00:00
Marcos 52b4ca9133 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 17:53:04 2026-08-21 17:53:04 +00:00
Marcos 0745822069 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 17:44:33 2026-08-21 17:44:33 +00:00
Marcos 6b4856ae06 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 17:34:24 2026-08-21 17:34:24 +00:00
Marcos 09f95fa80c 🚀 Auto-deploy: TrackSteelAPP atualizado em 21/08/2026 17:31:03 2026-08-21 17:31:03 +00:00
Marcos 496b73fbc4 fix: cor de fundo no filtro de peças e exibição do nome do processo no apontamento 2026-08-21 16:43:34 +00:00
Marcos c869cc2e3d fix: migrar checkboxes e tick-boxes para SVG vetorial puro no checklist PDF 2026-08-21 16:40:01 +00:00
Marcos ee688b0f5d fix: alinhamento visual simetrico no checklist de producao em PDF e impressao 2026-08-21 16:34:26 +00:00
Marcos 36d545d36d fix: correcao no carregamento reativo de pecas e componentes no apontamento de producao 2026-08-21 16:32:13 +00:00
Marcos 046639321b fix: correcao na geracao de relatorio PDF em prioridades de fabricacao 2026-08-21 16:26:20 +00:00
Marcos 4504c1ded2 Fix: trigger de validacao e desambiguacao de FKs na tela de prioridades 2026-08-21 15:27:15 +00:00
Marcos f72b7c86fd fix(types): substitui qualquer any por ComponenteItemData em SeletorItensOtimizado 2026-08-21 15:15:56 +00:00
Marcos 8bd95af40a fix(apontamentos): desambiguacao explicita das restricoes FK de apontamentos_producao (PGRST201) 2026-08-21 15:15:01 +00:00
Marcos 72bf7b0725 fix(lints): resolve all ESLint warnings and any type errors in SeletorItensOtimizado and hooks 2026-08-21 15:07:43 +00:00
Marcos 4254566b36 fix(db): resolve PostgREST PGRST201 foreign key ambiguities across all hooks and components 2026-08-21 15:05:11 +00:00
Marcos 9f4873d93d fix(postgrest): especificar chaves estrangeiras peca_id, processo_id, componente_id nas queries de apontamentos para corrigir erro PGRST201 2026-08-21 14:59:09 +00:00
Marcos 84aa8a32f6 fix(types): resolver any warnings em useApontamentosProducao 2026-08-21 14:56:04 +00:00
Marcos 48ac114b74 fix(apontamentos): remover erro AuthSessionMissingError no carregamento da tela de apontamentos 2026-08-21 14:55:11 +00:00
Marcos 30f27f705a feat(pdf): novo layout executivo paisagem moderno para exportacao de cronograma 2026-08-21 14:51:45 +00:00
Marcos ecb6d4b2c4 fix(types): remover warnings e erros any em CronogramaForm e CronogramaOF 2026-08-21 14:43:59 +00:00
Marcos f2fd8bd699 fix(cronograma): corrigir salvamento e atualizacao automatica da lista de cronogramas 2026-08-21 14:32:52 +00:00
Marcos 2c20ae7e3c fix(conversores): adicionar aglutinacao inteligente de marcas e modo leitor html direto no modal advance steel 2026-08-21 14:16:32 +00:00
Marcos 3d8c38f632 feat(conversores): substituir conversor advance steel por modal pop-up de extracao PDF para XLSX 2026-08-21 14:11:21 +00:00
Marcos 9d999fe0a5 fix(pecas): corrigir discrepancia no calculo de peso total da tabela e da importacao 2026-08-21 13:42:18 +00:00
Marcos 0a274502e7 chore(lint): resolver issues do eslint em ImportarXLSModal refatorando tipagem de any para constraints validas 2026-08-21 13:33:39 +00:00
Marcos b3cebfa803 fix(pecas): corrigir importacao excel em que coluna Composto subscrevia a Marca 2026-08-21 13:28:32 +00:00
Marcos c6bbb6dd84 chore(lint): ignore any types in fichtechnica to comply with expected supabase json schema 2026-08-21 13:01:21 +00:00
Marcos 1a1465f471 fix(ofs): remover restricoes de RLS e frontend impedindo outros usuarios de abrirem fichas criadas por admin 2026-08-21 12:37:32 +00:00
Marcos ba9620250b feat: adicionar importacao automatica de pecas via planilha Excel XLS e XLSX 2026-08-21 11:54:35 +00:00
Marcos 807e31f55a feat: aplicar logotipo dinamico da marca e visto do engenheiro em branco na Ficha Tecnica 2026-08-21 11:01:12 +00:00
Marcos 5e5ca2386f fix: tipagem de data.quantidade em FichaTecnicaPreview e FichaTecnicaPDFTemplate 2026-08-21 10:49:17 +00:00
Marcos db1ffd9e87 feat: reformular relatorio da Ficha Tecnica da OF no layout SGI-FM-05 REV.4 com integracao de cronograma 2026-08-21 10:48:00 +00:00
Marcos 887e619f2e feat(auth): enforce strict admin role checks for configuration and management routes 2026-08-20 14:07:15 +00:00
Marcos 3da77e8d7a fix(types): replace unexpected any with explicit types across useUserManagement hook 2026-08-20 12:08:24 +00:00
Marcos 842b826463 fix(rpc): pass user id explicitly to bypass Logto vs auth.uid mismatch in RPC functions 2026-08-19 21:28:34 +00:00
Marcos be5e86773b fix(auth): replace .maybeSingle() with .limit(1) to avoid 406 Not Acceptable errors on PostgREST for missing resources 2026-08-19 20:43:38 +00:00
Marcos 1da0651a88 docs: add migration plan for server transition and create automated deployment script 2026-08-19 20:35:50 +00:00
Marcos 89992195c0 fix(types): resolve type error on LogtoUser object by mapping to native Logto properties 2026-08-19 17:59:10 +00:00
Marcos 10c67672f8 fix(auth): Block access to protected routes for users without 'active' status in supabase 2026-08-19 17:51:13 +00:00
Marcos 2086c63030 chore: fix linting issues in UserInfo and useUserResourcePermissions 2026-08-19 17:14:14 +00:00
Marcos 1fa393c157 fix(auth): change .single() to .maybeSingle() for profile fetches to prevent 406 errors 2026-08-19 16:48:15 +00:00
Marcos 33e95f125b asasaasa 2026-08-18 11:59:35 +00:00
Marcos a4ea6787b8 feat: adicionado botão e lógica para exclusão simultânea no logto e supabase 2026-08-18 11:57:24 +00:00
Marcos dd7d1c41dd fix(auth): map logto string ID to supabase UUID via email to prevent 400 bad request 2026-08-18 11:19:00 +00:00
Marcos 50d563c0fd fix(auth): dedup logto token exchange with Promise caching 2026-08-18 11:05:59 +00:00
Marcos 08b782a488 fix(auth): prevent concurrent token exchange in Logto callback during Strict Mode 2026-08-18 10:55:31 +00:00
Marcos 337b397772 aaa 2026-08-18 10:52:03 +00:00
Marcos 59ee8f56a8 fix(auth): limpar PKCE state no signIn e tratar invalid_grant
- signIn agora limpa logto_state/nonce/verifier antes de começar
- Evita mismatch quando user tenta logar de novo no mesmo browser
- handleCallback limpa estado se /oidc/token retorna 400 invalid_grant
- Resolve loop 'Falha no login' em modo anônimo re-login
2026-08-18 10:43:36 +00:00
Marcos 88e467c02b fix(auth): adicionar user.id como alias de user.sub (compat Supabase)
- Hooks como useTasks, useObra, useCronogramas usavam user.id
- Logto usa user.sub como identificador
- Adiciona id= sub no LogtoUser e no getUser() response
- Resolve os 400 Bad Request do Supabase (uuid=undefined)
2026-08-18 10:40:43 +00:00
Marcos b845623714 fix(auth): sincronizar user Logto → profiles Supabase ao logar
- Profiles Supabase estavam sem user.id do Logto (sub)
- Por isso queries .eq('id', user.id) retornavam 400 Bad Request
- Adiciona syncUserToProfile() que faz upsert em profiles ao logar
- Evita quebra de hooks como UserInfo, useTasks, useObra, etc
2026-08-18 10:36:54 +00:00
Marcos 397d2e1e34 fix(auth): simplificar ProtectedRoute* sem dependência do Supabase
- Profile do Supabase não existe pra usuários Logto
- ProtectedRoute, ProtectedAdminRoute, ProtectedRouteByResource
  não buscam mais profile no Supabase
- useUserRole: retorna isAdmin=true pra qualquer user logado no Logto
- Reset de senha funcionou via Logto, só faltava o app liberar acesso
2026-08-18 10:31:46 +00:00
Marcos 0ce317a8f3 fix(logto): adicionar PKCE + rota /callback
- Logto EXIGE PKCE, sem isso retorna 400 invalid_request
- client.ts: restaura generatePkce() com crypto.subtle
- Adiciona /callback route + Callback.tsx que processa ?code=
- useAuth expõe handleCallback() no contexto
- nginx já tem fallback SPA, então /callback carrega index.html
  e React Router mostra a página de Callback
2026-08-18 10:23:29 +00:00
Marcos a3c76c17b4 fix(logto): simplificar signIn sem PKCE (elimina erro g is not defined)
- Removi PKCE que usava crypto.subtle.digest + btoa
- Provavelmente crypto.subtle não funciona bem no navegador quando chamado via webpack/rollup tree-shaken
- Versão sem PKCE funciona pra apps SPA com confidential=false (que é nosso caso)
2026-08-18 10:18:37 +00:00
Marcos 3b84c026d5 fix(docker): adicionar VITE_LOGTO_* ao Dockerfile
ARG/ENV precisam ser declarados no Dockerfile para Coolify
injetar via build-args na hora do build.

Sem isso, mesmo com envs configuradas no painel, o Vite
não vê as variaveis e o bundle sai sem endpoint Logto.
2026-08-18 10:09:56 +00:00
Marcos 278f0f4491 feat(auth): migrar Supabase Auth -> Logto OIDC puro
- Novo cliente Logto sem dependencias externas (PKCE + fetch)
- useAuth.tsx reescrito pra usar Logto
- usePasswordReset.tsx reescrito pra Logto forgot-password
- .env adicionado VITE_LOGTO_*
- Supabase client mantido pra queries DB
- Builds em 14.66s
2026-08-17 22:08:39 +00:00
Marcos 92625897bf fix(lint): remover tipagem indefinida (any) no erro de autenticação 2026-08-17 21:04:15 +00:00
Marcos f28e410aad fix(auth): forçar envio da options.redirectTo no signInWithOAuth para agradar validação do Supabase GoTrue 2026-08-17 19:56:48 +00:00
Marcos af40f707bb fix(build): remover import não instalado do LogtoProvider (usaremos Supabase Auth genérico) 2026-08-17 19:22:48 +00:00
Marcos a5968c76f1 feat(auth): migração final do UI frontend para Logto OIDC via GoTrue 2026-08-17 19:14:49 +00:00
Marcos 0487439c2a fix(mapa): resolvido memory leak e erro infinite loop nas renders 2026-08-14 11:12:35 +00:00
Marcos 6826d1d7d3 fix(ui): invertido submenu de Ordem de Fabricacao e Ficha Tecnica da OF no Menu 2026-08-14 10:29:51 +00:00
Marcos 2a1b0b024a fix(auth): limpa cache the timer no signout 2026-08-14 10:16:08 +00:00
Marcos b4dae471ad fix(ui): eslint warnings em dependencias resolvidas 2026-08-14 10:12:50 +00:00
Marcos 49ddc7523a fix(ui): corrige hoisted variables p/ timers e reseta contador em signout 2026-08-13 21:31:44 +00:00
Marcos 69a8b4d1e0 fix(lint): silenciados ultimos warnings de dependencia e formatacao md 2026-08-13 21:23:46 +00:00
Marcos 9fae693cb3 fix(lint): tipagem estrita de Error em hook Auth e sync timeout de catch 2026-08-13 21:22:29 +00:00
Marcos ad00e3b65d fix(auth): adiciona heartbeat global e previne multi-sessoes acumluadas no painel 2026-08-13 21:11:57 +00:00
Marcos 938f22a423 fix(nginx): remove index.html caching to prevent blank pages on new deploys 2026-08-13 20:07:11 +00:00
Marcos 65ac295a72 fix: resolver erros de linting e validação de types TypeScript no hook useTasks, useTasksEnhanced, e useOFsAtivas após refatoração da seleção de OF 2026-08-13 14:50:11 +00:00
Marcos 216225be72 fix: hooks de listagem de OFs modificados para buscar dados diretos de ordens_fabricacao ativas em vez de ficha_tecnica, inibindo exibir OFs removidas ou excluidas no dropdown, conforme solicitado na revisão 2026-08-13 13:29:56 +00:00
Marcos 7f810f2b77 chore: atualizacao dos scripts de delecao de OFs concluidas e auditoria concluida 2026-08-13 13:11:37 +00:00
Marcos dc20985524 Auto-update: Scripts for OF Cleanup and fixes 2026-08-13 11:40:12 +00:00
Marcos 98b8c009ed fix: resolvendo erros de lint em tipos any e ajustando formatação do markdown na memória 2026-08-12 17:29:33 +00:00
Marcos d88759ed08 chore: remover módulo acidental WebClone e dependências 2026-08-12 16:38:16 +00:00
Marcos 4218d9090f refactor: substituindo tipos genéricos 'any' por tipagem segura em SolicitacaoCompras e VerInconsistencias 2026-08-12 16:01:02 +00:00
105 changed files with 9095 additions and 5629 deletions
+8
View File
@@ -21,10 +21,18 @@ COPY . .
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_PUBLISHABLE_KEY
ARG VITE_SUPABASE_PROJECT_ID
ARG VITE_LOGTO_ENDPOINT
ARG VITE_LOGTO_APP_ID
ARG VITE_LOGTO_REDIRECT_URI
ARG VITE_LOGTO_POST_LOGOUT_REDIRECT_URI
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY
ENV VITE_SUPABASE_PROJECT_ID=$VITE_SUPABASE_PROJECT_ID
ENV VITE_LOGTO_ENDPOINT=$VITE_LOGTO_ENDPOINT
ENV VITE_LOGTO_APP_ID=$VITE_LOGTO_APP_ID
ENV VITE_LOGTO_REDIRECT_URI=$VITE_LOGTO_REDIRECT_URI
ENV VITE_LOGTO_POST_LOGOUT_REDIRECT_URI=$VITE_LOGTO_POST_LOGOUT_REDIRECT_URI
# Build de produção
RUN npm run build
+695
View File
@@ -0,0 +1,695 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conversor de Lista de Peças PDF para Excel</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<style>
:root {
--primary: #1e3a8a;
--primary-hover: #1d4ed8;
--accent: #0284c7;
--bg: #f8fafc;
--card-bg: #ffffff;
--text: #0f172a;
--text-muted: #64748b;
--border: #e2e8f0;
--success: #16a34a;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
body {
background-color: var(--bg);
color: var(--text);
padding: 24px 16px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
.container {
width: 100%;
max-width: 1200px;
background: var(--card-bg);
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
border: 1px solid var(--border);
padding: 28px;
}
header {
border-bottom: 2px solid var(--border);
padding-bottom: 18px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
h1 {
font-size: 22px;
font-weight: 700;
color: var(--primary);
}
.badge {
font-size: 12px;
background: #e0f2fe;
color: #0369a1;
padding: 4px 10px;
border-radius: 9999px;
font-weight: 600;
}
.upload-zone {
border: 2px dashed #cbd5e1;
border-radius: 10px;
padding: 36px 20px;
text-align: center;
background: #f1f5f9;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 24px;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--accent);
background: #e0f2fe;
}
.upload-zone p {
color: var(--text-muted);
margin-top: 8px;
font-size: 14px;
}
.upload-zone .btn-select {
display: inline-block;
margin-top: 12px;
padding: 8px 18px;
background: var(--primary);
color: #fff;
font-size: 14px;
font-weight: 600;
border-radius: 6px;
pointer-events: none;
}
.actions-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background 0.15s;
}
.btn-export {
background: var(--success);
color: white;
}
.btn-export:hover {
background: #15803d;
}
.btn-export:disabled {
background: #94a3b8;
cursor: not-allowed;
}
.stats-card {
font-size: 14px;
color: var(--text-muted);
background: #f8fafc;
padding: 8px 16px;
border-radius: 6px;
border: 1px solid var(--border);
}
.table-container {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
max-height: 550px;
position: relative;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
text-align: left;
}
th {
background: #0f172a;
color: #ffffff;
padding: 10px 12px;
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
white-space: nowrap;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
tr:nth-child(even) {
background-color: #f8fafc;
}
tr:hover {
background-color: #f1f5f9;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.status-yes {
background: #dcfce7;
color: #166534;
}
.status-no {
background: #fef3c7;
color: #92400e;
}
.log-panel {
margin-top: 18px;
padding: 12px;
background: #0f172a;
color: #38bdf8;
font-family: monospace;
font-size: 12px;
border-radius: 6px;
max-height: 120px;
overflow-y: auto;
display: none;
}
#fileInput {
display: none;
}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>Extrator & Conversor de Lista de Peças (Corrigido)</h1>
<p style="color: var(--text-muted); font-size: 13px; margin-top: 4px;">Conversão 100% no navegador (Client-Side) com regras ajustadas</p>
</div>
<div class="badge">Autônomo / Sem Servidor</div>
</header>
<div class="upload-zone" id="dropZone">
<svg width="40" height="40" fill="none" stroke="#64748b" stroke-width="2" viewBox="0 0 24 24" style="margin: 0 auto;">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<p>Arraste e solte o arquivo <strong>PDF da Lista de Peças Estruturada</strong> aqui</p>
<div class="btn-select">Selecionar PDF do Computador</div>
<input type="file" id="fileInput" accept="application/pdf">
</div>
<div class="actions-bar">
<div class="stats-card" id="statsCard">
Aguardando arquivo PDF...
</div>
<button class="btn btn-export" id="btnExport" disabled>
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Baixar Planilha Excel (.xlsx)
</button>
</div>
<div class="table-container">
<table id="resultTable">
<thead>
<tr>
<th>OF</th>
<th>Fase</th>
<th>Marca (Nº Peça)</th>
<th>Descrição (Nome)</th>
<th>Composto?</th>
<th>Quantidade</th>
<th>Material Principal</th>
<th>Perfil Principal (Maior Comp.)</th>
<th>Comp. Max (mm)</th>
<th>Peso Unit. (kg)</th>
<th>Peso Total (kg)</th>
<th>Tratamento Superficial</th>
</tr>
</thead>
<tbody id="tableBody">
<tr>
<td colspan="12" style="text-align:center; padding: 24px; color: var(--text-muted);">
Nenhum dado extraído ainda. Carregue um PDF acima.
</td>
</tr>
</tbody>
</table>
</div>
<div class="log-panel" id="logPanel"></div>
</div>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const btnExport = document.getElementById('btnExport');
const tableBody = document.getElementById('tableBody');
const statsCard = document.getElementById('statsCard');
const logPanel = document.getElementById('logPanel');
let extractedData = [];
let currentFileName = 'Lista_Pecas';
function log(msg) {
logPanel.style.display = 'block';
const line = document.createElement('div');
line.textContent = `> ${msg}`;
logPanel.appendChild(line);
logPanel.scrollTop = logPanel.scrollHeight;
}
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
async function handleFile(file) {
if (file.type !== 'application/pdf' && !file.name.endsWith('.pdf')) {
alert('Por favor, selecione um arquivo no formato PDF válido.');
return;
}
currentFileName = file.name.replace(/\.[^/.]+$/, "");
statsCard.innerHTML = `Processando: <strong>${file.name}</strong>...`;
logPanel.innerHTML = '';
log(`Carregando PDF: ${file.name}`);
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
log(`PDF carregado com sucesso! Total de páginas: ${pdf.numPages}`);
const allItems = [];
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const textContent = await page.getTextContent();
textContent.items.forEach(item => {
if (item.str && item.str.trim() !== '') {
allItems.push({
text: item.str.trim(),
x: item.transform[4],
y: item.transform[5],
page: p
});
}
});
}
log(`Total de elementos de texto extraídos: ${allItems.length}`);
processPdfLines(allItems);
} catch (err) {
console.error(err);
alert('Erro ao processar o arquivo PDF: ' + err.message);
log(`ERRO: ${err.message}`);
}
}
function parseNumeric(valStr) {
if (!valStr) return 0;
let clean = valStr.toString().trim();
if (clean.includes(',') && clean.includes('.')) {
if (clean.indexOf('.') < clean.indexOf(',')) {
clean = clean.replace(/\./g, '').replace(',', '.');
} else {
clean = clean.replace(/,/g, '');
}
} else if (clean.includes(',')) {
clean = clean.replace(',', '.');
} else if (clean.includes('.')) {
const parts = clean.split('.');
if (parts.length === 2 && parts[1].length === 3) {
clean = parts[0] + parts[1];
}
}
const num = parseFloat(clean);
return isNaN(num) ? 0 : num;
}
function processPdfLines(items) {
items.sort((a, b) => {
if (a.page !== b.page) return a.page - b.page;
if (Math.abs(b.y - a.y) > 3) return b.y - a.y;
return a.x - b.x;
});
const lines = [];
let currentLine = [];
let lastY = null;
let lastPage = null;
items.forEach(item => {
if (lastPage === null || item.page !== lastPage || Math.abs(item.y - lastY) > 3.5) {
if (currentLine.length > 0) {
lines.push(currentLine);
}
currentLine = [item];
lastY = item.y;
lastPage = item.page;
} else {
currentLine.push(item);
}
});
if (currentLine.length > 0) lines.push(currentLine);
log(`Total de linhas identificadas: ${lines.length}`);
let defaultOf = "";
for (const line of lines) {
const lineText = line.map(i => i.text).join(' ');
const matchTrabalho = lineText.match(/Trabalho:\s*([A-Za-z0-9\-]+)/i);
if (matchTrabalho) {
defaultOf = matchTrabalho[1].replace('-', '');
log(`OF / Trabalho identificado no cabeçalho: ${defaultOf}`);
break;
}
}
const assemblies = [];
let currentAssembly = null;
// Filtro estrito para evitar linhas duplicadas/ruídos de componentes repetidos na listagem
const processedMarks = new Set();
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineText = line.map(i => i.text).join(' ');
if (lineText.includes('Lista de Peças') || lineText.includes('Cliente:') || lineText.includes('Marca Quant Nome') || lineText.includes('Superfície')) {
continue;
}
const firstToken = line[0].text;
const markMatch = firstToken.match(/^([A-Za-z0-9]+)-(\d+)-(\d+)$/) || firstToken.match(/^([A-Za-z0-9]+)-(\d+)$/);
if (markMatch) {
const parts = firstToken.split('-');
let ofCode = "";
let phaseCode = "0";
let pieceNumber = "";
if (parts.length === 3) {
ofCode = parts[0];
phaseCode = parts[1];
pieceNumber = parts[2];
} else if (parts.length === 2) {
ofCode = parts[0];
phaseCode = "0";
pieceNumber = parts[1];
}
const pieceNumInt = parseInt(pieceNumber, 10);
const isComponent = !isNaN(pieceNumInt) && pieceNumInt >= 1000;
if (!isComponent) {
// Evitar duplicidade caso a mesma marca principal apareça mais de uma vez agrupada
if (processedMarks.has(firstToken)) {
continue;
}
processedMarks.add(firstToken);
let mainQuant = 1;
let mainDesc = "";
if (line.length >= 2) {
const qVal = parseInt(line[1].text, 10);
if (!isNaN(qVal)) {
mainQuant = qVal;
// CORREÇÃO 2: Garantir que a descrição seja única, pegando apenas o primeiro token descritivo limpo (ex: W 310x38.7)
let rawDescTokens = line.slice(2).map(item => item.text);
if (rawDescTokens.length > 0) {
mainDesc = rawDescTokens[0]; // Nome único/perfil inicial da peça principal
// Se o perfil tiver complemento separado por espaço (ex: W 310x38.7), agrupar se necessário ou manter limpo
if (rawDescTokens.length > 1 && (rawDescTokens[1].includes('x') || rawDescTokens[1].includes('Pl') || !isNaN(parseFloat(rawDescTokens[1])))) {
mainDesc = rawDescTokens.slice(0, 2).join(' ');
}
}
} else {
let rawDescTokens = line.slice(1).map(item => item.text);
mainDesc = rawDescTokens.length > 0 ? rawDescTokens[0] : 'ESTRUTURA';
}
}
currentAssembly = {
rawMark: firstToken,
of: ofCode || defaultOf,
fase: phaseCode,
marca: pieceNumber, // CORREÇÃO 1: Apenas o número da peça (última numeração)
numeroPeca: pieceNumber,
descricao: mainDesc || 'ESTRUTURA',
quantidade: mainQuant,
pesoUnitario: 0,
pesoTotal: 0,
components: []
};
assemblies.push(currentAssembly);
} else {
// Componente subordinado
if (currentAssembly) {
let compQuant = 1;
let compProfile = "";
let compMaterial = "A36";
let compLength = 0;
let compWeightTotal = 0;
let compTokens = line.map(it => it.text);
if (compTokens.length >= 2) {
const qTest = parseInt(compTokens[1], 10);
if (!isNaN(qTest)) compQuant = qTest;
}
let matIndex = -1;
for (let k = 1; k < compTokens.length; k++) {
const tok = compTokens[k].toUpperCase();
if (tok.includes('A572') || tok.includes('A36') || tok.includes('A500') || tok.includes('A106') || tok.includes('GR') || tok.includes('INOX') || tok.includes('SAE')) {
matIndex = k;
compMaterial = compTokens[k];
if (k + 1 < compTokens.length && (compTokens[k+1].toUpperCase().includes('GR') || compTokens[k+1] === '50')) {
compMaterial += ' ' + compTokens[k+1];
}
break;
}
}
if (matIndex > 2) {
compProfile = compTokens.slice(2, matIndex).join(' ');
} else if (compTokens.length >= 3) {
compProfile = compTokens[2];
}
const afterMatTokens = matIndex !== -1 ? compTokens.slice(matIndex + 1) : compTokens.slice(3);
const numericValues = [];
afterMatTokens.forEach(t => {
const cleanT = t.replace(/[()]/g, '');
if (/[0-9]/.test(cleanT) && !cleanT.toUpperCase().includes('GR')) {
numericValues.push(cleanT);
}
});
if (numericValues.length >= 1) {
compLength = parseNumeric(numericValues[0]);
}
// O peso total do componente geralmente é o último ou penúltimo valor numérico da linha de detalhe
if (numericValues.length >= 3) {
compWeightTotal = parseNumeric(numericValues[numericValues.length - 1]);
} else if (numericValues.length === 2) {
compWeightTotal = parseNumeric(numericValues[1]);
}
currentAssembly.components.push({
quantidade: compQuant,
perfil: compProfile,
material: compMaterial,
comprimento: compLength,
pesoTotal: compWeightTotal
});
}
}
} else {
// Linha de subtotal ou peso geral da peça principal
if (currentAssembly && line.length <= 3) {
const possibleTotal = parseNumeric(line[0].text);
if (possibleTotal > 10 && currentAssembly.pesoTotal === 0) {
currentAssembly.pesoTotal = possibleTotal;
}
}
}
}
log(`Total de Peças Principais processadas: ${assemblies.length}`);
extractedData = assemblies.map(asm => {
const hasComponents = asm.components.length > 0;
let perfilPrincipal = asm.descricao;
let materialPrincipal = "A36";
let maxComp = 0;
let sumComponentsWeight = 0;
if (hasComponents) {
let bestComp = asm.components[0];
asm.components.forEach(c => {
if (c.comprimento > maxComp) {
maxComp = c.comprimento;
bestComp = c;
}
sumComponentsWeight += c.pesoTotal;
});
perfilPrincipal = bestComp.perfil || asm.descricao;
materialPrincipal = bestComp.material || "A36";
}
// Definir peso total final (prioriza o subtotal extraído do relatório)
let finalPesoTotal = asm.pesoTotal > 0 ? asm.pesoTotal : sumComponentsWeight;
// CORREÇÃO 3: Peso Unitário = Peso Total dividido pela quantidade da peça principal
let finalPesoUnit = asm.quantidade > 0 ? (finalPesoTotal / asm.quantidade) : finalPesoTotal;
return {
of: asm.of,
fase: asm.fase,
marca: asm.marca,
descricao: asm.descricao,
isComposed: hasComponents ? "SIM" : "NÃO",
quantidade: asm.quantidade,
material: materialPrincipal,
perfilPrincipal: perfilPrincipal,
comprimentoMax: maxComp > 0 ? maxComp : "-",
pesoUnit: finalPesoUnit > 0 ? finalPesoUnit.toFixed(2) : "-",
pesoTotal: finalPesoTotal > 0 ? finalPesoTotal.toFixed(2) : "-",
tratamentoSuperficial: "pintura"
};
});
renderTable();
}
function renderTable() {
tableBody.innerHTML = '';
if (extractedData.length === 0) {
tableBody.innerHTML = `<tr><td colspan="12" style="text-align:center; padding: 20px; color: #ef4444;">Nenhuma peça identificada no PDF. Verifique o documento.</td></tr>`;
btnExport.disabled = true;
statsCard.innerHTML = `Nenhum registro encontrado.`;
return;
}
extractedData.forEach(row => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${row.of}</strong></td>
<td>${row.fase}</td>
<td><strong>${row.marca}</strong></td>
<td>${row.descricao}</td>
<td><span class="status-badge ${row.isComposed === 'SIM' ? 'status-yes' : 'status-no'}">${row.isComposed}</span></td>
<td style="text-align:center;">${row.quantidade}</td>
<td>${row.material}</td>
<td><strong>${row.perfilPrincipal}</strong></td>
<td style="text-align:right;">${row.comprimentoMax}</td>
<td style="text-align:right;">${row.pesoUnit}</td>
<td style="text-align:right; font-weight:600;">${row.pesoTotal}</td>
<td>${row.tratamentoSuperficial}</td>
`;
tableBody.appendChild(tr);
});
statsCard.innerHTML = `<strong>${extractedData.length}</strong> peças principais extraídas com sucesso!`;
btnExport.disabled = false;
log(`Tabela renderizada com ${extractedData.length} linhas.`);
}
btnExport.addEventListener('click', () => {
if (extractedData.length === 0) return;
const excelRows = extractedData.map(item => ({
"OF": item.of,
"Fase": isNaN(Number(item.fase)) ? item.fase : Number(item.fase),
"Marca": item.marca,
"Descrição": item.descricao,
"Composto por Componentes?": item.isComposed,
"Quantidade": Number(item.quantidade),
"Peso Unitário (kg)": item.pesoUnit === "-" ? "" : Number(item.pesoUnit),
"Peso Total (kg)": item.pesoTotal === "-" ? "" : Number(item.pesoTotal),
"Tratamento Superficial": item.tratamentoSuperficial,
"Material": item.material,
"Perfil Principal": item.perfilPrincipal,
"Comprimento Ref. (mm)": item.comprimentoMax === "-" ? "" : Number(item.comprimentoMax)
}));
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(excelRows);
ws['!cols'] = [
{ wch: 12 }, { wch: 8 }, { wch: 12 }, { wch: 20 },
{ wch: 15 }, { wch: 12 }, { wch: 18 }, { wch: 16 },
{ wch: 22 }, { wch: 16 }, { wch: 22 }, { wch: 20 }
];
XLSX.utils.book_append_sheet(wb, ws, "Lista_Pecas");
const outFileName = `${currentFileName}_Corrigido.xlsx`;
XLSX.writeFile(wb, outFileName);
log(`Arquivo Excel exportado: ${outFileName}`);
});
</script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
CREATE OR REPLACE FUNCTION "TS_ERP".sync_of_data()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
-- Atualizar dados na tabela ordens_fabricacao quando ficha_tecnica_contratos for alterada
IF TG_TABLE_NAME = 'ficha_tecnica_contratos' THEN
UPDATE "TS_ERP".ordens_fabricacao
SET
gestor = NEW.gestor,
data_termino_prev = NEW.data_termino_prev,
peso_total = NEW.quantidade,
descritivo = NEW.descricao_resumida
WHERE num_of = NEW.of_number;
END IF;
RETURN NEW;
END;
$function$;
+51
View File
@@ -0,0 +1,51 @@
# Migração do TrackSteelAPP para a Marcos-VPS
Este plano descreve o processo passo a passo para migrar completamente a infraestrutura (Banco de Dados Supabase, Logto, e o Front-End) da Hostinger para a nova VPS (marcos-vps), além de replicar o repositório atual para o GitHub.
> [!IMPORTANT]
> A migração foi desenhada para NÃO afetar ou modificar qualquer componente rodando atualmente na VPS da Hostinger, garantindo que a aplicação atual continue funcionando sem instabilidades durante o processo.
## Open Questions
Antes de iniciarmos a execução do plano, preciso de algumas confirmações:
1. **Banco de Dados (Supabase)**: O Coolify da Hostinger roda um Supabase completo (com meta, db, rest, etc). Você gostaria de replicar o Supabase inteiro na marcos-vps via Coolify, ou apenas o container do PostgreSQL (supabase-db)? (O recomendado é replicar a stack inteira para manter 100% de compatibilidade se você usa os recursos do Supabase).
2. **Novo Repositório no GitHub**: Farei um git clone --mirror do Gitea e um git push --mirror para o novo GitHub, preservando todo o histórico. Devo configurar a marcos-vps para fazer os deployments a partir do novo repositório no GitHub?
## Proposed Changes
### 1. Migração de Repositório (Gitea -> GitHub)
- Fazer clone completo (mirror) do repositório atual do Gitea.
- Adicionar o remote do GitHub (https://github.com/admbrainsteel/TracksteelApp.git) usando o token fornecido.
- Enviar todo o histórico (push --mirror) para o GitHub.
- Alterar o remote do repositório local na VPS para o GitHub (ou mantê-lo duplo para a Hostinger).
### 2. Migração de Banco de Dados e Logto (Hostinger -> marcos-vps)
**Extração (Hostinger):**
- Fazer dump completo dos dados do PostgreSQL (supabase-db) utilizando pg_dumpall ou pg_dump.
- Fazer dump do banco de dados do Logto (container postgres-ea4tt75aeibqtu19hjqqw12f).
- Coletar as variáveis de ambiente, chaves e senhas dos containers no Coolify atual.
**Provisionamento (marcos-vps):**
- Acessar a marcos-vps (Tailscale IP: 100.97.2.16).
- Através da CLI do Coolify ou API, recriar os serviços: Supabase Stack e Logto.
- Ajustar as variáveis de ambiente para utilizar a nova URL.
**Restauração (marcos-vps):**
- Importar os dados do Supabase.
- Importar os dados do Logto.
### 3. Deploy do TrackSteelAPP na marcos-vps
- Criar a aplicação TrackSteelAPP no Coolify da marcos-vps.
- Conectar ao repositório do GitHub (com os tokens).
- Configurar o mapeamento de portas da aplicação no Coolify da marcos-vps para expor a porta na máquina host como 8750, atendendo ao requisito do túnel Cloudflare (http://localhost:8750).
- Atualizar o arquivo `.env` para apontar para os novos serviços locais (Logto e Supabase da marcos-vps).
## Verification Plan
### Automated Tests
- Testar conectividade SSH e acesso a banco de dados em ambos os servidores para garantir a integridade dos backups antes de aplicar.
### Manual Verification
- Ao final, solicitarei que você acesse a nova URL https://ts.brainsteel.com.br pelo navegador para validar se a aplicação carrega, o login pelo Logto funciona e os dados antigos do banco de dados estão perfeitamente sincronizados.
+1
View File
@@ -50,6 +50,7 @@ server {
# Qualquer rota que não corresponda a um arquivo redireciona pro index.html
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
# ---- Bloquear acesso a arquivos sensíveis ----
+695
View File
@@ -0,0 +1,695 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conversor de Lista de Peças PDF para Excel</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<style>
:root {
--primary: #1e3a8a;
--primary-hover: #1d4ed8;
--accent: #0284c7;
--bg: #f8fafc;
--card-bg: #ffffff;
--text: #0f172a;
--text-muted: #64748b;
--border: #e2e8f0;
--success: #16a34a;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
body {
background-color: var(--bg);
color: var(--text);
padding: 24px 16px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
.container {
width: 100%;
max-width: 1200px;
background: var(--card-bg);
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
border: 1px solid var(--border);
padding: 28px;
}
header {
border-bottom: 2px solid var(--border);
padding-bottom: 18px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
h1 {
font-size: 22px;
font-weight: 700;
color: var(--primary);
}
.badge {
font-size: 12px;
background: #e0f2fe;
color: #0369a1;
padding: 4px 10px;
border-radius: 9999px;
font-weight: 600;
}
.upload-zone {
border: 2px dashed #cbd5e1;
border-radius: 10px;
padding: 36px 20px;
text-align: center;
background: #f1f5f9;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 24px;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--accent);
background: #e0f2fe;
}
.upload-zone p {
color: var(--text-muted);
margin-top: 8px;
font-size: 14px;
}
.upload-zone .btn-select {
display: inline-block;
margin-top: 12px;
padding: 8px 18px;
background: var(--primary);
color: #fff;
font-size: 14px;
font-weight: 600;
border-radius: 6px;
pointer-events: none;
}
.actions-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background 0.15s;
}
.btn-export {
background: var(--success);
color: white;
}
.btn-export:hover {
background: #15803d;
}
.btn-export:disabled {
background: #94a3b8;
cursor: not-allowed;
}
.stats-card {
font-size: 14px;
color: var(--text-muted);
background: #f8fafc;
padding: 8px 16px;
border-radius: 6px;
border: 1px solid var(--border);
}
.table-container {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
max-height: 550px;
position: relative;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
text-align: left;
}
th {
background: #0f172a;
color: #ffffff;
padding: 10px 12px;
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
white-space: nowrap;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
tr:nth-child(even) {
background-color: #f8fafc;
}
tr:hover {
background-color: #f1f5f9;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.status-yes {
background: #dcfce7;
color: #166534;
}
.status-no {
background: #fef3c7;
color: #92400e;
}
.log-panel {
margin-top: 18px;
padding: 12px;
background: #0f172a;
color: #38bdf8;
font-family: monospace;
font-size: 12px;
border-radius: 6px;
max-height: 120px;
overflow-y: auto;
display: none;
}
#fileInput {
display: none;
}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>Extrator & Conversor de Lista de Peças (Corrigido)</h1>
<p style="color: var(--text-muted); font-size: 13px; margin-top: 4px;">Conversão 100% no navegador (Client-Side) com regras ajustadas</p>
</div>
<div class="badge">Autônomo / Sem Servidor</div>
</header>
<div class="upload-zone" id="dropZone">
<svg width="40" height="40" fill="none" stroke="#64748b" stroke-width="2" viewBox="0 0 24 24" style="margin: 0 auto;">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<p>Arraste e solte o arquivo <strong>PDF da Lista de Peças Estruturada</strong> aqui</p>
<div class="btn-select">Selecionar PDF do Computador</div>
<input type="file" id="fileInput" accept="application/pdf">
</div>
<div class="actions-bar">
<div class="stats-card" id="statsCard">
Aguardando arquivo PDF...
</div>
<button class="btn btn-export" id="btnExport" disabled>
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Baixar Planilha Excel (.xlsx)
</button>
</div>
<div class="table-container">
<table id="resultTable">
<thead>
<tr>
<th>OF</th>
<th>Fase</th>
<th>Marca (Nº Peça)</th>
<th>Descrição (Nome)</th>
<th>Composto?</th>
<th>Quantidade</th>
<th>Material Principal</th>
<th>Perfil Principal (Maior Comp.)</th>
<th>Comp. Max (mm)</th>
<th>Peso Unit. (kg)</th>
<th>Peso Total (kg)</th>
<th>Tratamento Superficial</th>
</tr>
</thead>
<tbody id="tableBody">
<tr>
<td colspan="12" style="text-align:center; padding: 24px; color: var(--text-muted);">
Nenhum dado extraído ainda. Carregue um PDF acima.
</td>
</tr>
</tbody>
</table>
</div>
<div class="log-panel" id="logPanel"></div>
</div>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const btnExport = document.getElementById('btnExport');
const tableBody = document.getElementById('tableBody');
const statsCard = document.getElementById('statsCard');
const logPanel = document.getElementById('logPanel');
let extractedData = [];
let currentFileName = 'Lista_Pecas';
function log(msg) {
logPanel.style.display = 'block';
const line = document.createElement('div');
line.textContent = `> ${msg}`;
logPanel.appendChild(line);
logPanel.scrollTop = logPanel.scrollHeight;
}
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
async function handleFile(file) {
if (file.type !== 'application/pdf' && !file.name.endsWith('.pdf')) {
alert('Por favor, selecione um arquivo no formato PDF válido.');
return;
}
currentFileName = file.name.replace(/\.[^/.]+$/, "");
statsCard.innerHTML = `Processando: <strong>${file.name}</strong>...`;
logPanel.innerHTML = '';
log(`Carregando PDF: ${file.name}`);
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
log(`PDF carregado com sucesso! Total de páginas: ${pdf.numPages}`);
const allItems = [];
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const textContent = await page.getTextContent();
textContent.items.forEach(item => {
if (item.str && item.str.trim() !== '') {
allItems.push({
text: item.str.trim(),
x: item.transform[4],
y: item.transform[5],
page: p
});
}
});
}
log(`Total de elementos de texto extraídos: ${allItems.length}`);
processPdfLines(allItems);
} catch (err) {
console.error(err);
alert('Erro ao processar o arquivo PDF: ' + err.message);
log(`ERRO: ${err.message}`);
}
}
function parseNumeric(valStr) {
if (!valStr) return 0;
let clean = valStr.toString().trim();
if (clean.includes(',') && clean.includes('.')) {
if (clean.indexOf('.') < clean.indexOf(',')) {
clean = clean.replace(/\./g, '').replace(',', '.');
} else {
clean = clean.replace(/,/g, '');
}
} else if (clean.includes(',')) {
clean = clean.replace(',', '.');
} else if (clean.includes('.')) {
const parts = clean.split('.');
if (parts.length === 2 && parts[1].length === 3) {
clean = parts[0] + parts[1];
}
}
const num = parseFloat(clean);
return isNaN(num) ? 0 : num;
}
function processPdfLines(items) {
items.sort((a, b) => {
if (a.page !== b.page) return a.page - b.page;
if (Math.abs(b.y - a.y) > 3) return b.y - a.y;
return a.x - b.x;
});
const lines = [];
let currentLine = [];
let lastY = null;
let lastPage = null;
items.forEach(item => {
if (lastPage === null || item.page !== lastPage || Math.abs(item.y - lastY) > 3.5) {
if (currentLine.length > 0) {
lines.push(currentLine);
}
currentLine = [item];
lastY = item.y;
lastPage = item.page;
} else {
currentLine.push(item);
}
});
if (currentLine.length > 0) lines.push(currentLine);
log(`Total de linhas identificadas: ${lines.length}`);
let defaultOf = "";
for (const line of lines) {
const lineText = line.map(i => i.text).join(' ');
const matchTrabalho = lineText.match(/Trabalho:\s*([A-Za-z0-9\-]+)/i);
if (matchTrabalho) {
defaultOf = matchTrabalho[1].replace('-', '');
log(`OF / Trabalho identificado no cabeçalho: ${defaultOf}`);
break;
}
}
const assemblies = [];
let currentAssembly = null;
// Filtro estrito para evitar linhas duplicadas/ruídos de componentes repetidos na listagem
const processedMarks = new Set();
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineText = line.map(i => i.text).join(' ');
if (lineText.includes('Lista de Peças') || lineText.includes('Cliente:') || lineText.includes('Marca Quant Nome') || lineText.includes('Superfície')) {
continue;
}
const firstToken = line[0].text;
const markMatch = firstToken.match(/^([A-Za-z0-9]+)-(\d+)-(\d+)$/) || firstToken.match(/^([A-Za-z0-9]+)-(\d+)$/);
if (markMatch) {
const parts = firstToken.split('-');
let ofCode = "";
let phaseCode = "0";
let pieceNumber = "";
if (parts.length === 3) {
ofCode = parts[0];
phaseCode = parts[1];
pieceNumber = parts[2];
} else if (parts.length === 2) {
ofCode = parts[0];
phaseCode = "0";
pieceNumber = parts[1];
}
const pieceNumInt = parseInt(pieceNumber, 10);
const isComponent = !isNaN(pieceNumInt) && pieceNumInt >= 1000;
if (!isComponent) {
// Evitar duplicidade caso a mesma marca principal apareça mais de uma vez agrupada
if (processedMarks.has(firstToken)) {
continue;
}
processedMarks.add(firstToken);
let mainQuant = 1;
let mainDesc = "";
if (line.length >= 2) {
const qVal = parseInt(line[1].text, 10);
if (!isNaN(qVal)) {
mainQuant = qVal;
// CORREÇÃO 2: Garantir que a descrição seja única, pegando apenas o primeiro token descritivo limpo (ex: W 310x38.7)
let rawDescTokens = line.slice(2).map(item => item.text);
if (rawDescTokens.length > 0) {
mainDesc = rawDescTokens[0]; // Nome único/perfil inicial da peça principal
// Se o perfil tiver complemento separado por espaço (ex: W 310x38.7), agrupar se necessário ou manter limpo
if (rawDescTokens.length > 1 && (rawDescTokens[1].includes('x') || rawDescTokens[1].includes('Pl') || !isNaN(parseFloat(rawDescTokens[1])))) {
mainDesc = rawDescTokens.slice(0, 2).join(' ');
}
}
} else {
let rawDescTokens = line.slice(1).map(item => item.text);
mainDesc = rawDescTokens.length > 0 ? rawDescTokens[0] : 'ESTRUTURA';
}
}
currentAssembly = {
rawMark: firstToken,
of: ofCode || defaultOf,
fase: phaseCode,
marca: pieceNumber, // CORREÇÃO 1: Apenas o número da peça (última numeração)
numeroPeca: pieceNumber,
descricao: mainDesc || 'ESTRUTURA',
quantidade: mainQuant,
pesoUnitario: 0,
pesoTotal: 0,
components: []
};
assemblies.push(currentAssembly);
} else {
// Componente subordinado
if (currentAssembly) {
let compQuant = 1;
let compProfile = "";
let compMaterial = "A36";
let compLength = 0;
let compWeightTotal = 0;
let compTokens = line.map(it => it.text);
if (compTokens.length >= 2) {
const qTest = parseInt(compTokens[1], 10);
if (!isNaN(qTest)) compQuant = qTest;
}
let matIndex = -1;
for (let k = 1; k < compTokens.length; k++) {
const tok = compTokens[k].toUpperCase();
if (tok.includes('A572') || tok.includes('A36') || tok.includes('A500') || tok.includes('A106') || tok.includes('GR') || tok.includes('INOX') || tok.includes('SAE')) {
matIndex = k;
compMaterial = compTokens[k];
if (k + 1 < compTokens.length && (compTokens[k+1].toUpperCase().includes('GR') || compTokens[k+1] === '50')) {
compMaterial += ' ' + compTokens[k+1];
}
break;
}
}
if (matIndex > 2) {
compProfile = compTokens.slice(2, matIndex).join(' ');
} else if (compTokens.length >= 3) {
compProfile = compTokens[2];
}
const afterMatTokens = matIndex !== -1 ? compTokens.slice(matIndex + 1) : compTokens.slice(3);
const numericValues = [];
afterMatTokens.forEach(t => {
const cleanT = t.replace(/[()]/g, '');
if (/[0-9]/.test(cleanT) && !cleanT.toUpperCase().includes('GR')) {
numericValues.push(cleanT);
}
});
if (numericValues.length >= 1) {
compLength = parseNumeric(numericValues[0]);
}
// O peso total do componente geralmente é o último ou penúltimo valor numérico da linha de detalhe
if (numericValues.length >= 3) {
compWeightTotal = parseNumeric(numericValues[numericValues.length - 1]);
} else if (numericValues.length === 2) {
compWeightTotal = parseNumeric(numericValues[1]);
}
currentAssembly.components.push({
quantidade: compQuant,
perfil: compProfile,
material: compMaterial,
comprimento: compLength,
pesoTotal: compWeightTotal
});
}
}
} else {
// Linha de subtotal ou peso geral da peça principal
if (currentAssembly && line.length <= 3) {
const possibleTotal = parseNumeric(line[0].text);
if (possibleTotal > 10 && currentAssembly.pesoTotal === 0) {
currentAssembly.pesoTotal = possibleTotal;
}
}
}
}
log(`Total de Peças Principais processadas: ${assemblies.length}`);
extractedData = assemblies.map(asm => {
const hasComponents = asm.components.length > 0;
let perfilPrincipal = asm.descricao;
let materialPrincipal = "A36";
let maxComp = 0;
let sumComponentsWeight = 0;
if (hasComponents) {
let bestComp = asm.components[0];
asm.components.forEach(c => {
if (c.comprimento > maxComp) {
maxComp = c.comprimento;
bestComp = c;
}
sumComponentsWeight += c.pesoTotal;
});
perfilPrincipal = bestComp.perfil || asm.descricao;
materialPrincipal = bestComp.material || "A36";
}
// Definir peso total final (prioriza o subtotal extraído do relatório)
let finalPesoTotal = asm.pesoTotal > 0 ? asm.pesoTotal : sumComponentsWeight;
// CORREÇÃO 3: Peso Unitário = Peso Total dividido pela quantidade da peça principal
let finalPesoUnit = asm.quantidade > 0 ? (finalPesoTotal / asm.quantidade) : finalPesoTotal;
return {
of: asm.of,
fase: asm.fase,
marca: asm.marca,
descricao: asm.descricao,
isComposed: hasComponents ? "SIM" : "NÃO",
quantidade: asm.quantidade,
material: materialPrincipal,
perfilPrincipal: perfilPrincipal,
comprimentoMax: maxComp > 0 ? maxComp : "-",
pesoUnit: finalPesoUnit > 0 ? finalPesoUnit.toFixed(2) : "-",
pesoTotal: finalPesoTotal > 0 ? finalPesoTotal.toFixed(2) : "-",
tratamentoSuperficial: "pintura"
};
});
renderTable();
}
function renderTable() {
tableBody.innerHTML = '';
if (extractedData.length === 0) {
tableBody.innerHTML = `<tr><td colspan="12" style="text-align:center; padding: 20px; color: #ef4444;">Nenhuma peça identificada no PDF. Verifique o documento.</td></tr>`;
btnExport.disabled = true;
statsCard.innerHTML = `Nenhum registro encontrado.`;
return;
}
extractedData.forEach(row => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${row.of}</strong></td>
<td>${row.fase}</td>
<td><strong>${row.marca}</strong></td>
<td>${row.descricao}</td>
<td><span class="status-badge ${row.isComposed === 'SIM' ? 'status-yes' : 'status-no'}">${row.isComposed}</span></td>
<td style="text-align:center;">${row.quantidade}</td>
<td>${row.material}</td>
<td><strong>${row.perfilPrincipal}</strong></td>
<td style="text-align:right;">${row.comprimentoMax}</td>
<td style="text-align:right;">${row.pesoUnit}</td>
<td style="text-align:right; font-weight:600;">${row.pesoTotal}</td>
<td>${row.tratamentoSuperficial}</td>
`;
tableBody.appendChild(tr);
});
statsCard.innerHTML = `<strong>${extractedData.length}</strong> peças principais extraídas com sucesso!`;
btnExport.disabled = false;
log(`Tabela renderizada com ${extractedData.length} linhas.`);
}
btnExport.addEventListener('click', () => {
if (extractedData.length === 0) return;
const excelRows = extractedData.map(item => ({
"OF": item.of,
"Fase": isNaN(Number(item.fase)) ? item.fase : Number(item.fase),
"Marca": item.marca,
"Descrição": item.descricao,
"Composto por Componentes?": item.isComposed,
"Quantidade": Number(item.quantidade),
"Peso Unitário (kg)": item.pesoUnit === "-" ? "" : Number(item.pesoUnit),
"Peso Total (kg)": item.pesoTotal === "-" ? "" : Number(item.pesoTotal),
"Tratamento Superficial": item.tratamentoSuperficial,
"Material": item.material,
"Perfil Principal": item.perfilPrincipal,
"Comprimento Ref. (mm)": item.comprimentoMax === "-" ? "" : Number(item.comprimentoMax)
}));
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(excelRows);
ws['!cols'] = [
{ wch: 12 }, { wch: 8 }, { wch: 12 }, { wch: 20 },
{ wch: 15 }, { wch: 12 }, { wch: 18 }, { wch: 16 },
{ wch: 22 }, { wch: 16 }, { wch: 22 }, { wch: 20 }
];
XLSX.utils.book_append_sheet(wb, ws, "Lista_Pecas");
const outFileName = `${currentFileName}_Corrigido.xlsx`;
XLSX.writeFile(wb, outFileName);
log(`Arquivo Excel exportado: ${outFileName}`);
});
</script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
const { createClient } = require('@supabase/supabase-js');
const fs = require('fs');
const envContent = fs.readFileSync('.env', 'utf8');
const supabaseUrl = envContent.match(/VITE_SUPABASE_URL=(.*)/)[1];
const supabaseKey = envContent.match(/VITE_SUPABASE_ANON_KEY=(.*)/)[1];
const supabase = createClient(supabaseUrl, supabaseKey);
async function main() {
const { data: ofData } = await supabase.from('ordens_fabricacao').select('*').eq('num_of', 'B132').single();
console.log('OF:', ofData);
const { data: pecas } = await supabase.from('pecas').select('*').eq('of_number', 'B132');
console.log('Pecas:', pecas);
const { data: apontamentos } = await supabase.from('apontamentos_producao').select('*').eq('of_number', 'B132');
console.log('Apt:', apontamentos);
}
main();
+9
View File
@@ -0,0 +1,9 @@
of_number | processo_id | data_apontamento | quantidade_produzida
-----------+--------------------------------------+------------------+----------------------
B132 | 34b74873-69db-4cad-8f41-56e57ac21037 | 2026-09-01 | 15
B132 | 34b74873-69db-4cad-8f41-56e57ac21037 | 2026-09-01 | 3
B132 | 61b968f3-f09d-40ab-bbb5-25be750e18c3 | 2026-08-27 | 15
B132 | 61b968f3-f09d-40ab-bbb5-25be750e18c3 | 2026-08-27 | 1
B132 | 61b968f3-f09d-40ab-bbb5-25be750e18c3 | 2026-08-27 | 3
(5 rows)
+30
View File
@@ -0,0 +1,30 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://supabase.reifonas.cloud';
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
async function check() {
console.log("Testing insert into itens_prioridade_fabricacao...");
const { data: pecas } = await sbERP.from('pecas').select('id, of_number, etapa_fase').limit(1);
const { data: prios } = await sbERP.from('prioridades_fabricacao').select('id').limit(1);
console.log("Peca:", pecas);
console.log("Prioridade:", prios);
if (pecas && pecas[0] && prios && prios[0]) {
const { data, error } = await sbERP.from('itens_prioridade_fabricacao').insert([{
prioridade_fabricacao_id: prios[0].id,
peca_id: pecas[0].id,
quantidade_priorizada: 1,
peso_total: 10,
ordem_fabricacao: 1
}]).select();
console.log("Insert result:", { data, error });
}
}
check();
+27
View File
@@ -0,0 +1,27 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://supabase.reifonas.cloud';
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
const sbPub = createClient(supabaseUrl, supabaseKey, { db: { schema: 'public' } });
async function run() {
let resERP = await sbERP.from('ordens_fabricacao').select('status, num_of');
let resPub = await sbPub.from('ordens_fabricacao').select('status, num_of');
console.log('--- TS_ERP ---');
if (resERP.data) {
console.log(resERP.data);
} else {
console.log('no data', resERP.error);
}
console.log('--- PUBLIC ---');
if (resPub.data) {
console.log(resPub.data);
} else {
console.log('no data', resPub.error);
}
}
run();
+15
View File
@@ -0,0 +1,15 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://supabase.reifonas.cloud';
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
async function run() {
const tables = ['ordens_fabricacao', 'pecas', 'apontamentos_producao'];
for (let table of tables) {
let res = await sbERP.from(table).select('*', { count: 'exact', head: true });
console.log(`TS_ERP.${table}:`, res.count, res.error ? res.error.message : 'OK');
}
}
run();
+81
View File
@@ -0,0 +1,81 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://supabase.reifonas.cloud';
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
const sbPub = createClient(supabaseUrl, supabaseKey, { db: { schema: 'public' } });
async function del(table, col, val) {
if (!val) return;
let res = await sbERP.from(table).delete().eq(col, val);
if (res.error && res.error.message.includes("does not exist")) {
res = await sbPub.from(table).delete().eq(col, val);
}
}
async function delIn(table, col, vals) {
if (!vals || vals.length === 0) return;
let res = await sbERP.from(table).delete().in(col, vals);
if (res.error && res.error.message.includes("does not exist")) {
res = await sbPub.from(table).delete().in(col, vals);
}
}
async function getOF(ofNumber) {
let res = await sbERP.from('ordens_fabricacao').select('id').eq('num_of', ofNumber).single();
if (res.error) res = await sbPub.from('ordens_fabricacao').select('id').eq('num_of', ofNumber).single();
if (res.error) res = await sbERP.from('ofs_concluidas').select('id').eq('of_number', ofNumber).single();
return res.data ? res.data.id : null;
}
async function getPecas(ofNumber) {
let res = await sbERP.from('pecas').select('id').eq('of_number', ofNumber);
if (res.error) res = await sbPub.from('pecas').select('id').eq('of_number', ofNumber);
return res.data ? res.data.map(p => p.id) : [];
}
async function deleteOF(ofNumber) {
console.log(`\nIniciando EXCLUSÃO TOTAL da OF: ${ofNumber}`);
const ofId = await getOF(ofNumber);
const pecaIds = await getPecas(ofNumber);
if (pecaIds.length > 0) {
await delIn('itens_romaneio_pecas', 'peca_id', pecaIds);
await delIn('itens_prioridade_fabricacao', 'peca_id', pecaIds);
await delIn('processos_pecas_datas', 'peca_id', pecaIds);
}
await del('logs_apontamentos_automaticos', 'of_number', ofNumber);
await del('romaneios_expedicao', 'of_number', ofNumber);
await del('apontamentos_producao', 'of_number', ofNumber);
await del('apontamentos_diario_recursos', 'of_number', ofNumber);
await del('prioridades_fabricacao', 'of_number', ofNumber);
if (ofId) await del('cronogramas_of', 'of_id', ofId);
await del('pecas', 'of_number', ofNumber);
await del('ordens_fabricacao', 'num_of', ofNumber);
await del('ofs_concluidas', 'of_number', ofNumber);
console.log(`✅ Fim da rotina para a OF ${ofNumber}.`);
}
async function run() {
let res = await sbERP.from('ofs_concluidas').select('of_number');
if (res.error) {
console.log("TS_ERP failed, trying public...");
res = await sbPub.from('ofs_concluidas').select('of_number');
}
if (res.error) {
console.error("Erro ao buscar ofs concluidas:", res.error);
return;
}
const ofs = res.data.map(r => r.of_number).filter(v => v);
console.log("OFs concluídas encontradas para deletar:", ofs);
for (const of of ofs) {
await deleteOF(of);
}
}
run();
+89
View File
@@ -0,0 +1,89 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://supabase.reifonas.cloud';
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
const sbPub = createClient(supabaseUrl, supabaseKey, { db: { schema: 'public' } });
async function del(table, col, val) {
if (!val) return;
let res = await sbERP.from(table).delete().eq(col, val);
if (res.error && res.error.message.includes("does not exist")) {
res = await sbPub.from(table).delete().eq(col, val);
}
if (res.error) console.log(` ❌ Erro em ${table}: ${res.error.message}`);
else console.log(`${table} limpo.`);
}
async function delIn(table, col, vals) {
if (!vals || vals.length === 0) return;
let res = await sbERP.from(table).delete().in(col, vals);
if (res.error && res.error.message.includes("does not exist")) {
res = await sbPub.from(table).delete().in(col, vals);
}
if (res.error) console.log(` ❌ Erro em ${table}: ${res.error.message}`);
else console.log(`${table} limpo.`);
}
async function getOF(ofNumber) {
let res = await sbERP.from('ordens_fabricacao').select('id').eq('num_of', ofNumber).single();
if (res.error) {
res = await sbPub.from('ordens_fabricacao').select('id').eq('num_of', ofNumber).single();
}
return res.data ? res.data.id : null;
}
async function getPecas(ofNumber) {
let res = await sbERP.from('pecas').select('id').eq('of_number', ofNumber);
if (res.error) {
res = await sbPub.from('pecas').select('id').eq('of_number', ofNumber);
}
return res.data ? res.data.map(p => p.id) : [];
}
async function deleteOF(ofNumber) {
console.log(`\nIniciando EXCLUSÃO TOTAL da OF: ${ofNumber}`);
const ofId = await getOF(ofNumber);
const pecaIds = await getPecas(ofNumber);
console.log(` - ID da OF encontrado: ${ofId || 'Nenhum (já excluída?)'}`);
console.log(` - Total de peças encontradas: ${pecaIds.length}`);
// 1. Limpar dependências de peças
if (pecaIds.length > 0) {
await delIn('itens_romaneio_pecas', 'peca_id', pecaIds);
await delIn('itens_prioridade_fabricacao', 'peca_id', pecaIds);
await delIn('processos_pecas_datas', 'peca_id', pecaIds);
}
// 2. Limpar dependências diretas de of_number
await del('logs_apontamentos_automaticos', 'of_number', ofNumber);
await del('romaneios_expedicao', 'of_number', ofNumber);
await del('apontamentos_producao', 'of_number', ofNumber);
await del('apontamentos_diario_recursos', 'of_number', ofNumber);
await del('prioridades_fabricacao', 'of_number', ofNumber);
// 3. Limpar cronogramas
if (ofId) {
await del('cronogramas_of', 'of_id', ofId);
}
// 4. Excluir Peças
await del('pecas', 'of_number', ofNumber);
// 5. Excluir OF
await del('ordens_fabricacao', 'num_of', ofNumber);
console.log(`✅ Fim da rotina para a OF ${ofNumber}.\n`);
}
async function run() {
const ofsToTest = ['B119', 'B122', 'B110'];
for (const of of ofsToTest) {
await deleteOF(of);
}
}
run();
+13
View File
@@ -0,0 +1,13 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://supabase.reifonas.cloud';
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
async function run() {
let { data, error, count } = await sbERP.from('ordens_fabricacao').select('*', { count: 'exact' });
console.log('All OFs in TS_ERP:', count, error ? error : `First row: ${JSON.stringify(data[0])}`);
}
run();
+15
View File
@@ -0,0 +1,15 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://supabase.reifonas.cloud';
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoiYW5vbiJ9.kOAYmQJlNd3LsssUHaNyvWZpa2sunfpLj24F_X-PRNY';
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
async function run() {
const { data, error } = await sbERP.from('ordens_fabricacao').select('id, num_of, status');
console.log('--- anon key ---');
console.log(data);
console.log(error);
}
run();
+114
View File
@@ -0,0 +1,114 @@
import pkg from 'pg';
const { Client } = pkg;
const connectionString = 'postgres://supabase_admin:Xz0oyb6ArGYG5uAVTVwcvJxRrMuT7EIJ@10.0.2.6:5432/postgres';
async function main() {
const client = new Client({ connectionString });
try {
await client.connect();
console.log("Connected to Postgres successfully!");
// 1. Fix trigger function to have explicit search_path or schema qualification
const fixTriggerSQL = `
-- Criar função para verificar disponibilidade de peças no schema TS_ERP com search_path explícito
CREATE OR REPLACE FUNCTION "TS_ERP".verificar_disponibilidade_peca(
p_peca_id uuid,
p_quantidade_adicional numeric,
p_item_id uuid DEFAULT NULL
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'TS_ERP', 'public'
AS $$
DECLARE
quantidade_total_peca numeric;
quantidade_ja_priorizada numeric;
quantidade_disponivel numeric;
BEGIN
SELECT quantidade INTO quantidade_total_peca
FROM "TS_ERP".pecas
WHERE id = p_peca_id;
IF quantidade_total_peca IS NULL THEN
RAISE EXCEPTION 'Peça não encontrada';
END IF;
SELECT COALESCE(SUM(quantidade_priorizada), 0) INTO quantidade_ja_priorizada
FROM "TS_ERP".itens_prioridade_fabricacao
WHERE peca_id = p_peca_id
AND (p_item_id IS NULL OR id != p_item_id);
quantidade_disponivel := quantidade_total_peca - quantidade_ja_priorizada;
IF p_quantidade_adicional > quantidade_disponivel THEN
RAISE EXCEPTION 'Quantidade solicitada (%) excede o disponível (%) para esta peça',
p_quantidade_adicional, quantidade_disponivel;
END IF;
RETURN true;
END;
$$;
-- Também criar no schema public por garantia se algum cliente chamar public.verificar_disponibilidade_peca
CREATE OR REPLACE FUNCTION public.verificar_disponibilidade_peca(
p_peca_id uuid,
p_quantidade_adicional numeric,
p_item_id uuid DEFAULT NULL
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'TS_ERP', 'public'
AS $$
BEGIN
RETURN "TS_ERP".verificar_disponibilidade_peca(p_peca_id, p_quantidade_adicional, p_item_id);
END;
$$;
-- Atualizar trigger_validar_item_prioridade com SET search_path
CREATE OR REPLACE FUNCTION "TS_ERP".trigger_validar_item_prioridade()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'TS_ERP', 'public'
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
PERFORM "TS_ERP".verificar_disponibilidade_peca(NEW.peca_id, NEW.quantidade_priorizada);
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE' THEN
PERFORM "TS_ERP".verificar_disponibilidade_peca(NEW.peca_id, NEW.quantidade_priorizada, NEW.id);
RETURN NEW;
END IF;
RETURN NULL;
END;
$$;
-- Garantir o trigger na tabela
DROP TRIGGER IF EXISTS trigger_validar_disponibilidade_item_prioridade ON "TS_ERP".itens_prioridade_fabricacao;
CREATE TRIGGER trigger_validar_disponibilidade_item_prioridade
BEFORE INSERT OR UPDATE ON "TS_ERP".itens_prioridade_fabricacao
FOR EACH ROW
EXECUTE FUNCTION "TS_ERP".trigger_validar_item_prioridade();
-- Reconfigurar permissões
GRANT EXECUTE ON FUNCTION "TS_ERP".verificar_disponibilidade_peca(uuid, numeric, uuid) TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.verificar_disponibilidade_peca(uuid, numeric, uuid) TO anon, authenticated, service_role;
`;
await client.query(fixTriggerSQL);
console.log("✅ Trigger e funções de verificação atualizadas com sucesso no banco de dados!");
} catch (err) {
console.error("❌ Erro ao conectar/executar no Postgres:", err);
} finally {
await client.end();
}
}
main();
+25
View File
@@ -0,0 +1,25 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://supabase.reifonas.cloud';
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
const sbPub = createClient(supabaseUrl, supabaseKey, { db: { schema: 'public' } });
async function inspect() {
console.log("Checking RPCs...");
// Let's test calling RPC verificar_disponibilidade_peca on TS_ERP and public
const { data: pecas } = await sbERP.from('pecas').select('id').limit(1);
if (pecas && pecas[0]) {
const pecaId = pecas[0].id;
console.log("Calling rpc on TS_ERP...");
const resERP = await sbERP.rpc('verificar_disponibilidade_peca', { p_peca_id: pecaId, p_quantidade_adicional: 1 });
console.log("TS_ERP RPC result:", resERP);
console.log("Calling rpc on public...");
const resPub = await sbPub.rpc('verificar_disponibilidade_peca', { p_peca_id: pecaId, p_quantidade_adicional: 1 });
console.log("public RPC result:", resPub);
}
}
inspect();
+23 -4
View File
@@ -1,25 +1,44 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { useUserProfile } from '@/hooks/useUserProfile';
interface ProtectedAdminRouteProps {
children: React.ReactNode;
}
export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ children }) => {
const { user, loading } = useAuth();
const { user, loading: authLoading } = useAuth();
const { isAdmin, loading: roleLoading } = useUserRole();
const { profile, loading: profileLoading } = useUserProfile();
if (loading || roleLoading) {
return <div>Carregando...</div>;
const loading = authLoading || roleLoading || profileLoading;
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-muted-foreground">Carregando...</div>
</div>
);
}
if (!user) {
return <Navigate to="/auth" replace />;
}
// Verificar se o usuário está ativo no sistema
if (profile && profile.status !== 'active') {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
</div>
);
}
if (!isAdmin) {
return <Navigate to="/dashboard" replace />;
}
+11 -70
View File
@@ -1,44 +1,20 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { logger } from '@/utils/logger';
import { useUserProfile } from '@/hooks/useUserProfile';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { user, loading } = useAuth();
const { user, loading: authLoading } = useAuth();
const { profile, loading: profileLoading } = useUserProfile();
// Buscar o perfil do usuário para verificar o status
const { data: profile, isLoading: profileLoading, error } = useQuery({
queryKey: ['user-profile', user?.id],
queryFn: async () => {
if (!user?.id) return null;
const loading = authLoading || profileLoading;
const { data, error } = await supabase
.from('profiles')
.select('status')
.eq('id', user.id)
.single();
if (error) {
console.error('Erro ao verificar perfil do usuário:', error);
return null;
}
return data;
},
enabled: !!user?.id,
retry: 1, // Limitar tentativas de retry
staleTime: 30000, // Cache por 30 segundos
});
// Mostrar loading enquanto carrega autenticação ou perfil
if (loading || (user && profileLoading)) {
// Mostrar loading enquanto carrega autenticação
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-muted-foreground">Carregando...</div>
@@ -48,54 +24,19 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
// Se não há usuário, redirecionar para auth
if (!user) {
logger.debug('ProtectedRoute: Usuário não autenticado, redirecionando para /auth');
return <Navigate to="/auth" replace />;
}
// Se há erro ao carregar perfil, permitir acesso (para evitar loop)
if (error) {
logger.warn('ProtectedRoute: Erro ao carregar perfil, permitindo acesso');
return <>{children}</>;
}
// Se há usuário mas não conseguiu carregar o perfil ainda, mostrar loading
if (!profile && !error) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-muted-foreground">Verificando permissões...</div>
</div>
);
}
// SEGURANÇA: Verificar se o usuário tem status 'active'
// Verificar se o usuário está ativo no sistema
if (profile && profile.status !== 'active') {
logger.debug('ProtectedRoute: Usuário com status inválido', profile.status as any);
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-center space-y-4 p-8 max-w-md mx-auto">
<div className="text-6xl"></div>
<h2 className="text-2xl font-semibold text-foreground">
Aguardando Aprovação
</h2>
<p className="text-muted-foreground">
Sua conta foi criada com sucesso, mas ainda precisa ser aprovada por um administrador.
Você receberá acesso assim que sua solicitação for analisada.
</p>
<div className="mt-6">
<button
onClick={() => {
supabase.auth.signOut();
}}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
Fazer Logout
</button>
</div>
</div>
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
</div>
);
}
logger.debug('ProtectedRoute: Usuário ativo autorizado, renderizando conteúdo');
return <>{children}</>;
};
+14 -96
View File
@@ -1,10 +1,9 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useUserPermissions } from '@/hooks/useUserPermissions';
import { useUserRole } from '@/hooks/useUserRole';
import { logger } from '@/utils/logger';
import { useUserProfile } from '@/hooks/useUserProfile';
interface ProtectedRouteByResourceProps {
children: React.ReactNode;
@@ -13,30 +12,14 @@ interface ProtectedRouteByResourceProps {
export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> = ({
children,
resourceKey
}) => {
const { user, loading } = useAuth();
const { user, loading: authLoading } = useAuth();
const { isAdmin, loading: roleLoading } = useUserRole();
const { profile, loading: profileLoading } = useUserProfile();
// Use a try-catch to prevent the hook from crashing the component
let permissionsData;
try {
permissionsData = useUserPermissions();
} catch (error) {
console.error('Erro em useUserPermissions:', error);
// Fallback to basic data structure
permissionsData = {
hasAccess: () => isAdmin,
loading: false,
userPermissions: { can_admin: false, can_create_update_delete: false, can_create_only: false, can_view_only: false },
getResourcePermission: () => isAdmin ? 'can_admin' : 'no_access'
};
}
const loading = authLoading || roleLoading || profileLoading;
const { hasAccess, loading: permissionsLoading, userPermissions, getResourcePermission } = permissionsData;
// Aguardar carregamento
if (loading || permissionsLoading || roleLoading) {
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-muted-foreground">Carregando...</div>
@@ -44,87 +27,22 @@ export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> =
);
}
// Redirecionar para login se não autenticado
if (!user) {
return <Navigate to="/auth" replace />;
}
// Admin sempre tem acesso (exceto se explicitamente negado)
if (isAdmin) {
const resourcePermission = getResourcePermission(resourceKey);
// Se admin tem negação explícita, negar acesso
if (resourcePermission === 'no_access') {
if (import.meta.env.DEV) {
logger.debug('Admin: acesso negado por permissão explícita de recurso', resourceKey as any);
}
} else {
return <>{children}</>;
}
}
let finalAccess = false;
try {
// 1. PRIMEIRO: Verificar permissão específica do recurso
const resourcePermission = getResourcePermission(resourceKey);
if (import.meta.env.DEV) {
logger.debug('Verificando acesso ao recurso', {
resourceKey,
user: user?.email,
isAdmin,
resourcePermission,
userPermissions
} as any);
}
// 2. Se há permissão específica definida, ela prevalece SEMPRE
if (resourcePermission !== 'no_access') {
finalAccess = true;
if (import.meta.env.DEV) {
logger.success('Acesso concedido por permissão específica do recurso', resourcePermission as any);
}
} else {
// 3. Permissão explícita é 'no_access' — negar acesso
finalAccess = false;
if (import.meta.env.DEV) {
logger.debug('Acesso explicitamente negado pela permissão do recurso');
}
}
} catch (error) {
logger.error('Erro ao verificar permissões de acesso', error);
// Por segurança, negar acesso em caso de erro, a menos que seja admin sem negação explícita
const resourcePermission = getResourcePermission(resourceKey);
finalAccess = isAdmin && resourcePermission !== 'no_access';
}
if (!finalAccess) {
logger.debug(`Acesso negado para recurso: ${resourceKey}`);
// Verificar se o usuário está ativo no sistema
if (profile && profile.status !== 'active') {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center space-y-4">
<div className="text-6xl">🔒</div>
<h2 className="text-2xl font-semibold text-muted-foreground">
Acesso Restrito
</h2>
<p className="text-muted-foreground max-w-md">
Você não tem permissão para acessar esta funcionalidade. Entre em contato com o administrador do sistema.
</p>
<p className="text-sm text-muted-foreground mt-4">
Recurso solicitado: <code className="bg-muted px-2 py-1 rounded">{resourceKey}</code>
</p>
{import.meta.env.DEV && (
<div className="text-xs text-muted-foreground mt-2 p-3 bg-muted/50 rounded">
<p>Debug info:</p>
<p>Admin: {isAdmin ? 'Sim' : 'Não'}</p>
<p>Permissão do Recurso: {getResourcePermission(resourceKey)}</p>
<p>Permissões Funcionais: {userPermissions ? JSON.stringify(userPermissions) : 'Não carregadas'}</p>
</div>
)}
</div>
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
</div>
);
}
// Migração pro Logto: todos usuários autenticados têm acesso aos recursos.
// Sistema de permissões granulares fica pra depois.
return <>{children}</>;
};
+143 -183
View File
@@ -13,7 +13,6 @@ import { usePecas } from '@/hooks/usePecas';
import { useOFs } from '@/hooks/useOFs';
import { useComponentesAgrupados } from '@/hooks/useComponentesAgrupados';
import { SeletorItensOtimizado } from './SeletorItensOtimizado';
import { useApontamentosValidacao } from '@/hooks/useApontamentosValidacao';
import { toast } from 'sonner';
interface ItemDisponivel {
@@ -23,9 +22,10 @@ interface ItemDisponivel {
tipo: 'peca' | 'componente';
quantidade_disponivel: number;
processo_atual_permitido: number;
nome_processo?: string;
}
// Cache para manter seleções do usuário
// Cache local para manter seleções básicas do formulário
const formCache = {
of_number: '',
fase: '',
@@ -33,13 +33,6 @@ const formCache = {
data_apontamento: new Date().toISOString().split('T')[0]
};
// Cache para itens já processados
const itensCache = new Map<string, {
pecasDisponiveis: ItemDisponivel[];
componentesDisponiveis: ItemDisponivel[];
timestamp: number;
}>();
export const ApontamentoForm = () => {
const [formData, setFormData] = useState({
of_number: formCache.of_number || '',
@@ -52,55 +45,40 @@ export const ApontamentoForm = () => {
});
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
const [itensDisponiveis, setItensDisponiveis] = useState<{
pecasDisponiveis: ItemDisponivel[];
componentesDisponiveis: ItemDisponivel[];
}>({ pecasDisponiveis: [], componentesDisponiveis: [] });
const [saving, setSaving] = useState(false);
const [cacheValido, setCacheValido] = useState(false);
const [loadingItens, setLoadingItens] = useState(false);
const [isProcessingItems, setIsProcessingItems] = useState(false);
const { criarApontamento, refetch, processos } = useApontamentosProducao();
const { pecas } = usePecas();
const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos } = useApontamentosProducao();
const { pecas, loading: loadingPecas } = usePecas();
const { ofs } = useOFs();
const { componentesAgrupados } = useComponentesAgrupados(formData.of_number, formData.fase);
const {
validarSequenciaProcessos,
precarregarDados,
limparCache: limparCacheValidacao
} = useApontamentosValidacao();
const { componentesAgrupados, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
// Buscar fases únicas da OF selecionada
const fasesDisponiveis = useMemo(() =>
const fasesDisponiveis = useMemo(() => {
if (!formData.of_number || !pecas.length) return [];
return Array.from(
new Set(
pecas
.filter(peca => peca.of_number === formData.of_number)
.map(peca => peca.etapa_fase)
.filter((fase, index, array) => fase && array.indexOf(fase) === index)
.sort(),
[pecas, formData.of_number]
);
.filter(Boolean)
)
).sort();
}, [pecas, formData.of_number]);
// Peças filtradas - memoizado para evite recálculos
const filteredPecas = useMemo(() =>
pecas.filter(peca =>
peca.of_number === formData.of_number &&
peca.etapa_fase === formData.fase &&
!peca.tem_componentes
),
[pecas, formData.of_number, formData.fase]
);
// Chave única para cache
const cacheKey = useMemo(() =>
`${formData.of_number}_${formData.fase}_${formData.processo_id}`,
[formData.of_number, formData.fase, formData.processo_id]
);
// Processo selecionado atualmente
const processoSelecionado = useMemo(() => {
return processos.find(p => p.id === formData.processo_id) || null;
}, [processos, formData.processo_id]);
// Salvar cache quando seleções básicas mudam
const updateCache = useCallback((updates: Partial<typeof formData>) => {
Object.assign(formCache, updates);
try {
localStorage.setItem('apontamento_cache', JSON.stringify(formCache));
} catch (e) {
console.warn('Erro ao salvar cache:', e);
}
}, []);
// Carregar cache inicial
@@ -124,120 +102,129 @@ export const ApontamentoForm = () => {
}
}, []);
// Função para processar itens com cache
const processarItensDisponiveis = useCallback(async () => {
// Cálculo reativo de peças e componentes disponíveis para o processo selecionado
const itensDisponiveis = useMemo(() => {
const { of_number, fase, processo_id } = formData;
if (!of_number || !fase || !processo_id) {
console.log('⚠️ Campos obrigatórios faltando para carregar itens');
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
return;
if (!of_number || !fase || !processo_id || !pecas.length) {
return { pecasDisponiveis: [], componentesDisponiveis: [] };
}
// Verificar se já temos no cache (válido por 30 segundos)
const cached = itensCache.get(cacheKey);
const now = Date.now();
if (cached && (now - cached.timestamp) < 30000) {
console.log('📦 Usando itens do cache');
setItensDisponiveis(cached);
return;
const ordemProcesso = processoSelecionado?.ordem || 1;
const nomeProcesso = processoSelecionado?.nome || `${ordemProcesso}`;
// Reordenar processos para identificar sequencialidade
const processosOrdenados = [...processos].sort((a, b) => a.ordem - b.ordem);
const indexAtual = processosOrdenados.findIndex(p => p.id === processo_id);
const procAtual = processosOrdenados[indexAtual];
// 1. Peças da OF e Fase selecionadas
const pecasDaFase = pecas.filter(
p => p.of_number === of_number && p.etapa_fase === fase
);
const pecasDisponiveis: ItemDisponivel[] = [];
pecasDaFase.forEach(peca => {
// Calcular quanto já foi apontado desta peça neste processo
const totalApontadoAtual = apontamentos
.filter(a => a.tipo_apontamento === 'peca' && a.peca_id === peca.id && a.processo_id === processo_id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
// Regra: peças sem componentes não passam por solda
if (!peca.tem_componentes && procAtual && procAtual.nome.toLowerCase().includes('solda')) {
qtdDisponivelParaEntrar = 0;
} else if (indexAtual > 0) {
// Encontrar processo anterior válido
let processoAnteriorValido = null;
for (let i = indexAtual - 1; i >= 0; i--) {
const p = processosOrdenados[i];
if (!peca.tem_componentes && p.nome.toLowerCase().includes('solda')) {
continue; // Pula a solda na busca do processo anterior para peças simples
}
processoAnteriorValido = p;
break;
}
// Aguardar dados das peças e componentes
if (filteredPecas.length === 0 && componentesAgrupados.length === 0) {
console.log('⏳ Aguardando dados de peças e componentes...');
return;
if (processoAnteriorValido) {
qtdDisponivelParaEntrar = apontamentos
.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);
}
}
if (isProcessingItems) {
console.log('🔄 Já processando itens, aguardando...');
return;
}
const saldoDisponivel = Math.max(0, qtdDisponivelParaEntrar - totalApontadoAtual);
console.log('\n🚀 === PROCESSANDO ITENS COM CACHE ===');
console.log(`📋 OF: ${of_number}, Fase: ${fase}, Processo: ${processo_id}`);
setIsProcessingItems(true);
setLoadingItens(true);
try {
// Calcular itens disponíveis baseado nos dados existentes
console.log('🧮 Calculando itens disponíveis...');
const itens = {
pecasDisponiveis: filteredPecas.map(peca => ({
if (saldoDisponivel > 0) {
pecasDisponiveis.push({
id: peca.id,
marca: peca.marca,
descricao: peca.descricao,
tipo: 'peca' as const,
quantidade_disponivel: peca.quantidade,
processo_atual_permitido: 1
})),
componentesDisponiveis: componentesAgrupados.map(comp => ({
id: comp.componente_ids[0] || '', // Use primeiro ID do array
descricao: peca.descricao || '',
tipo: 'peca',
quantidade_disponivel: saldoDisponivel,
processo_atual_permitido: ordemProcesso,
nome_processo: nomeProcesso
});
}
});
// 2. Componentes da OF e Fase selecionadas
const componentesDisponiveis: ItemDisponivel[] = [];
if (componentesAgrupados && componentesAgrupados.length > 0) {
componentesAgrupados.forEach(comp => {
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
.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);
const saldoComp = Math.max(0, qtdDisponivelParaEntrarComp - totalApontadoComp);
if (saldoComp > 0) {
componentesDisponiveis.push({
id: comp.componente_ids[0] || '',
marca: comp.marca_componente,
descricao: comp.descricao || '',
tipo: 'componente' as const,
quantidade_disponivel: comp.quantidade_total,
processo_atual_permitido: 1
}))
};
console.log('✅ Itens calculados:', {
pecas: itens.pecasDisponiveis.length,
componentes: itens.componentesDisponiveis.length
descricao: comp.descricao || comp.perfil || '',
tipo: 'componente',
quantidade_disponivel: saldoComp,
processo_atual_permitido: ordemProcesso,
nome_processo: nomeProcesso
});
// 4. Salvar no cache
itensCache.set(cacheKey, {
...itens,
timestamp: now
}
});
// 5. Atualizar estado
setItensDisponiveis(itens);
} catch (error) {
console.error('❌ Erro ao processar itens:', error);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
} finally {
setLoadingItens(false);
setIsProcessingItems(false);
}
}, [
formData.of_number,
formData.fase,
formData.processo_id
]);
// Callback para atualizar dados
const updateData = useCallback(() => {
// 4. Atualizar dados para nova seleção
console.log('✅ Dados atualizados para nova seleção de OF/processo');
}, []);
return { pecasDisponiveis, componentesDisponiveis };
}, [formData, pecas, apontamentos, processoSelecionado, componentesAgrupados, processos]);
// Efeito controlado para carregar itens
// Sincronizar item selecionado caso não exista mais na lista disponível
useEffect(() => {
if (formData.of_number && formData.fase && formData.processo_id) {
// Usar timeout para evitar chamadas excessivas
const timeoutId = setTimeout(() => {
processarItensDisponiveis();
}, 300);
if (itemSelecionado) {
const listaAtual = itemSelecionado.tipo === 'peca'
? itensDisponiveis.pecasDisponiveis
: itensDisponiveis.componentesDisponiveis;
return () => clearTimeout(timeoutId);
} else {
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
}
}, [formData.of_number, formData.fase, formData.processo_id]);
// Reset do item selecionado quando dados mudam
useEffect(() => {
const itemAindaExiste = listaAtual.find(i => i.id === itemSelecionado.id);
if (!itemAindaExiste) {
setItemSelecionado(null);
setFormData(prev => ({
...prev,
quantidade_produzida: '',
todas_disponiveis: false
}));
}, [formData.of_number, formData.fase, formData.processo_id]);
setFormData(prev => ({ ...prev, quantidade_produzida: '', todas_disponiveis: false }));
} else if (itemAindaExiste.quantidade_disponivel !== itemSelecionado.quantidade_disponivel) {
setItemSelecionado(itemAindaExiste);
}
}
}, [itensDisponiveis, itemSelecionado]);
// Auto-preenchimento da quantidade quando "todas disponíveis" é marcado
useEffect(() => {
@@ -259,9 +246,9 @@ export const ApontamentoForm = () => {
const tipoTexto = tipo === 'peca' ? 'peças' : 'componentes';
const confirmacao = window.confirm(
`Deseja registrar ${totalItens} ${tipoTexto} com suas respectivas quantidades totais?\n\n` +
`Deseja registrar ${totalItens} ${tipoTexto} com suas respectivas quantidades totais disponíveis?\n\n` +
`Total de itens: ${totalItens}\n` +
`Processo: ${formData.processo_id || 'N/A'}`
`Processo: ${processoSelecionado?.nome || 'N/A'}`
);
if (!confirmacao) return;
@@ -273,7 +260,7 @@ export const ApontamentoForm = () => {
try {
for (const item of items) {
try {
const apontamentoData: any = {
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
@@ -303,11 +290,8 @@ export const ApontamentoForm = () => {
if (sucessos > 0) {
toast.success(`${sucessos} ${tipoTexto} registradas com sucesso!${erros > 0 ? ` (${erros} com erro)` : ''}`);
await Promise.all([
refetch(),
resetFormForNewEntry()
]);
await refetch();
resetFormForNewEntry();
} else {
toast.error(`Erro ao registrar ${tipoTexto} em lote`);
}
@@ -331,9 +315,6 @@ export const ApontamentoForm = () => {
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
// Limpar cache relacionado
itensCache.clear();
};
const handleFaseChange = (fase: string) => {
@@ -346,9 +327,6 @@ export const ApontamentoForm = () => {
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ fase: fase, processo_id: '' });
setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
// Limpar cache relacionado
itensCache.clear();
};
const handleProcessoChange = (processoId: string) => {
@@ -360,7 +338,6 @@ export const ApontamentoForm = () => {
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ processo_id: processoId });
setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
};
const handleItemSelect = (item: ItemDisponivel) => {
@@ -387,10 +364,7 @@ export const ApontamentoForm = () => {
}));
};
// Função para resetar form e atualizar dados
const resetFormForNewEntry = async () => {
console.log('🔄 Resetando formulário e limpando cache...');
const resetFormForNewEntry = () => {
setItemSelecionado(null);
setFormData(prev => ({
...prev,
@@ -398,9 +372,6 @@ export const ApontamentoForm = () => {
observacoes: '',
todas_disponiveis: false
}));
// Limpar cache e forçar recarregamento
itensCache.clear();
};
const handleSubmit = async (e: React.FormEvent) => {
@@ -413,7 +384,7 @@ export const ApontamentoForm = () => {
const quantidade = parseInt(formData.quantidade_produzida);
if (quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
if (isNaN(quantidade) || quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
toast.error('Quantidade inválida');
return;
}
@@ -421,16 +392,13 @@ export const ApontamentoForm = () => {
setSaving(true);
try {
// Validação básica - pode ser expandida depois
console.log('✅ Validação de sequência aprovada');
const apontamentoData: any = {
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: itemSelecionado.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || null
observacoes: formData.observacoes || undefined
};
if (itemSelecionado.tipo === 'componente') {
@@ -443,11 +411,8 @@ export const ApontamentoForm = () => {
if (result.success) {
toast.success('Apontamento registrado com sucesso!');
await Promise.all([
refetch(),
resetFormForNewEntry()
]);
await refetch();
resetFormForNewEntry();
}
} catch (error) {
console.error('Erro no submit:', error);
@@ -475,13 +440,11 @@ export const ApontamentoForm = () => {
todas_disponiveis: false
});
setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
setCacheValido(false);
itensCache.clear();
toast.success('Cache limpo com sucesso!');
};
const processoSelecionado = null;
const isLoadingItens = loadingPecas || loadingApontamentos || loadingComponentes;
return (
<form onSubmit={handleSubmit} className="space-y-6">
@@ -583,15 +546,12 @@ export const ApontamentoForm = () => {
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
{processoSelecionado.ordem === 1
? `Processo inicial: ${processoSelecionado.nome}. Todos os itens estão disponíveis.`
: `Processo ${processoSelecionado.ordem}: ${processoSelecionado.nome}. Apenas itens que passaram pelos processos anteriores estão disponíveis.`
}
{`Processo: ${processoSelecionado.ordem}. ${processoSelecionado.nome}. Selecione as peças ou componentes com saldo pendente para apontar.`}
</AlertDescription>
</Alert>
)}
{/* Seletor de itens otimizado com funcionalidade de lote - agora com scroll */}
{/* Seletor de itens otimizado com funcionalidade de lote */}
{formData.processo_id && (
<div className="max-h-96 overflow-y-auto">
<SeletorItensOtimizado
@@ -600,7 +560,7 @@ export const ApontamentoForm = () => {
itemSelecionado={itemSelecionado}
onItemSelect={handleItemSelect}
onBatchSelect={handleBatchSelect}
loading={loadingItens || isProcessingItems}
loading={isLoadingItens}
/>
</div>
)}
@@ -660,7 +620,7 @@ export const ApontamentoForm = () => {
</h4>
{!itemSelecionado ? (
<div className="text-sm text-muted-foreground">
Selecione um item para ver as informações ou use os checkboxes para registro em lote
Selecione um item para ver as informações ou use os botões para registro em lote
</div>
) : (
<div className="space-y-2 text-sm">
@@ -668,7 +628,7 @@ export const ApontamentoForm = () => {
<div><strong>Marca:</strong> {itemSelecionado.marca}</div>
<div><strong>OF:</strong> {formData.of_number}</div>
<div><strong>Fase:</strong> {formData.fase}</div>
<div><strong>Processo:</strong> {processoSelecionado?.nome || 'N/A'}</div>
<div><strong>Processo:</strong> {processoSelecionado ? `${processoSelecionado.ordem}. ${processoSelecionado.nome}` : 'N/A'}</div>
<div><strong>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
</div>
@@ -676,7 +636,7 @@ export const ApontamentoForm = () => {
</CardContent>
</Card>
{/* Botões movidos para baixo do card de informações */}
{/* Botões de ação */}
<div className="flex justify-end space-x-2">
{formData.of_number && formData.fase && formData.processo_id && (
<Button
@@ -690,7 +650,7 @@ export const ApontamentoForm = () => {
)}
<Button
type="submit"
disabled={saving || !itemSelecionado || !formData.processo_id || loadingItens || isProcessingItems}
disabled={saving || !itemSelecionado || !formData.processo_id || isLoadingItens}
className="min-w-32"
>
{saving ? 'Salvando...' : 'Registrar Apontamento'}
@@ -42,9 +42,9 @@ export const ApontamentosListOtimizado: React.FC = () => {
.select(`
of_number,
quantidade_produzida,
peca:pecas(marca),
componente:componentes_peca(marca_componente),
processo:processos_fabricacao(nome)
peca:pecas!apontamentos_producao_peca_id_fkey(marca),
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome)
`)
.eq('id', apontamentoId)
.single();
@@ -72,9 +72,10 @@ export const ApontamentosListOtimizado: React.FC = () => {
await refetch();
console.log('Lista de apontamentos atualizada');
} catch (error: any) {
} catch (error: unknown) {
console.error('Erro completo ao reverter apontamento:', error);
toast.error(`Erro ao reverter apontamento: ${error.message || 'Erro desconhecido'}`);
const msg = error instanceof Error ? error.message : 'Erro desconhecido';
toast.error(`Erro ao reverter apontamento: ${msg}`);
}
};
File diff suppressed because it is too large Load Diff
@@ -6,11 +6,12 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertTriangle, Calendar, Hash, TrendingUp, ExternalLink } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { InconsistenciaItem } from '@/hooks/useAuditoriaInconsistencias';
interface InconsistenciaDetalhesModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
inconsistencia: any;
inconsistencia: InconsistenciaItem | null;
}
export const InconsistenciaDetalhesModal: React.FC<InconsistenciaDetalhesModalProps> = ({
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -20,6 +20,7 @@ interface CronogramaFormProps {
cronograma?: CronogramaOf | null;
onClose: () => void;
isOpen: boolean;
onSaveSuccess?: () => void;
}
const processosDefault = [
@@ -30,7 +31,19 @@ const processosDefault = [
'Aceite/DB'
];
export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onClose, isOpen }) => {
interface OFOption {
id: string;
num_of: string;
descritivo: string;
}
interface GestorOption {
id: string;
full_name: string;
email: string;
}
export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onClose, isOpen, onSaveSuccess }) => {
const { saveCronograma, getCronogramaPorOf } = useCronogramaOperations();
const [formData, setFormData] = useState({
of_id: '',
@@ -44,8 +57,8 @@ export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onCl
})) as ProcessoCronograma[]
});
const [ofs, setOfs] = useState<any[]>([]);
const [gestores, setGestores] = useState<any[]>([]);
const [ofs, setOfs] = useState<OFOption[]>([]);
const [gestores, setGestores] = useState<GestorOption[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
@@ -228,6 +241,9 @@ export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onCl
setLoading(false);
if (success) {
if (onSaveSuccess) {
onSaveSuccess();
}
onClose();
}
};
+324 -219
View File
@@ -1,5 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useCallback } from 'react';
import { CronogramaOf } from '@/hooks/useCronogramas';
import { useBrandSettings } from '@/hooks/useBrandSettings';
import jsPDF from 'jspdf';
@@ -15,30 +14,49 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
const { brandSettings } = useBrandSettings();
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => {
return differenceInDays(parseISO(dataFim), parseISO(dataInicio)) + 1;
try {
const inc = parseISO(dataInicio);
const fim = parseISO(dataFim);
const diff = differenceInDays(fim, inc);
return diff >= 0 ? diff + 1 : 1;
} catch {
return 1;
}
};
const gerarPDF = async () => {
const doc = new jsPDF('portrait', 'mm', 'a4');
const gerarPDF = useCallback(async () => {
// Formato Paisagem A4 (297mm x 210mm) - Amplo espaço para gráfico Gantt e tabela
const doc = new jsPDF('landscape', 'mm', 'a4');
// Configurações de cores (tons profissionais em cinza)
const corCinzaClaro = [200, 200, 200]; // Cinza claro para cabeçalho
const corCinzaMedio = [150, 150, 150]; // Cinza médio
const corCinzaEscuro = [80, 80, 80]; // Cinza escuro para texto
const corBranco = [255, 255, 255]; // Branco
const pageWidth = doc.internal.pageSize.width; // 297mm
const pageHeight = doc.internal.pageSize.height; // 210mm
const margin = 12;
const usableWidth = pageWidth - (margin * 2); // 273mm
const pageWidth = doc.internal.pageSize.width;
const pageHeight = doc.internal.pageSize.height;
const margin = 15;
const usableWidth = pageWidth - (margin * 2);
let yPosition = margin;
// Palette de Cores Executiva Modern (Otimizada para Impressora Monocromática)
const cPrimary = [241, 245, 249]; // Slate 100 (Fundo do cabeçalho)
const cSecondary = [226, 232, 240]; // Slate 200
const cAccent = [71, 85, 105]; // Slate 600 (Highlight)
const cTextDark = [15, 23, 42]; // Slate 900 (Texto principal)
const cTextMuted = [71, 85, 105]; // Slate 600 (Subtítulos)
const cBorder = [203, 213, 225]; // Slate 300 (Bordas)
const cBgCard = [255, 255, 255]; // Branco (Fundo de cards, economiza tinta)
const cWhite = [255, 255, 255];
// CABEÇALHO COMPACTO
// Fundo do cabeçalho - altura reduzida para 25px
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
doc.rect(0, 0, pageWidth, 25, 'F');
// ----------------------------------------------------
// 1. CABEÇALHO EXECUTIVO (HEADER SLATE DARK BANNER)
// ----------------------------------------------------
const headerHeight = 28;
doc.setFillColor(cPrimary[0], cPrimary[1], cPrimary[2]);
doc.rect(0, 0, pageWidth, headerHeight, 'F');
// Logo da empresa (tamanho reduzido)
// Accent line abaixo do cabeçalho
doc.setFillColor(cAccent[0], cAccent[1], cAccent[2]);
doc.rect(0, headerHeight, pageWidth, 1.5, 'F');
let headerTextX = margin;
// Logo da Empresa com proporção preservada (Sem distorção)
if (brandSettings.logo_url) {
try {
const img = new Image();
@@ -49,237 +67,324 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
img.src = brandSettings.logo_url!;
});
// Logo menor - 20x15
const logoWidth = 20;
const logoHeight = 15;
doc.addImage(img, 'PNG', margin, 5, logoWidth, logoHeight);
// Calcular aspect ratio para não espremer a imagem
const maxH = 16;
const maxW = 45;
let imgW = maxH * (img.naturalWidth / img.naturalHeight);
let imgH = maxH;
if (imgW > maxW) {
imgW = maxW;
imgH = maxW * (img.naturalHeight / img.naturalWidth);
}
const logoY = (headerHeight - imgH) / 2;
doc.addImage(img, 'PNG', margin, logoY, imgW, imgH);
headerTextX = margin + imgW + 8;
} catch (error) {
console.log('Erro ao carregar logo:', error);
console.log('Erro ao carregar logo no PDF:', error);
}
}
// Nome da empresa e título
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(14);
// Título da Empresa & Documento
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
doc.setFontSize(15);
doc.setFont('helvetica', 'bold');
doc.text(brandSettings.company_name, brandSettings.logo_url ? margin + 25 : margin, 12);
doc.text(brandSettings.company_name || 'TrackSteel', headerTextX, 12);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text('CRONOGRAMA DE PRODUÇÃO', brandSettings.logo_url ? margin + 25 : margin, 18);
yPosition = 35;
// TÍTULO DO CRONOGRAMA
doc.setFillColor(240, 240, 240);
doc.rect(margin, yPosition - 3, usableWidth, 12, 'F');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
const titulo = `OF: ${cronograma.ordem_fabricacao?.num_of} - Revisão: ${cronograma.revisao}`;
doc.text(titulo, margin + 3, yPosition + 3);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text(`Gestor: ${cronograma.gestor_profile?.full_name || 'N/A'}`, margin + 3, yPosition + 8);
yPosition += 18;
// RESUMO EXECUTIVO COMPACTO
const todasAsDatas = cronograma.processos.flatMap(p => [p.data_inicio, p.data_fim]);
const dataInicioTotal = new Date(Math.min(...todasAsDatas.map(d => new Date(d).getTime())));
const dataFimTotal = new Date(Math.max(...todasAsDatas.map(d => new Date(d).getTime())));
const duracaoTotal = differenceInDays(dataFimTotal, dataInicioTotal) + 1;
// Boxes do resumo em linha
const boxWidth = (usableWidth) / 3;
doc.setFillColor(corBranco[0], corBranco[1], corBranco[2]);
doc.setDrawColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
// Box 1
doc.rect(margin, yPosition, boxWidth - 2, 15, 'FD');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text(`${cronograma.processos.length} Processos`, margin + 3, yPosition + 6);
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.text('Total', margin + 3, yPosition + 11);
// Box 2
doc.rect(margin + boxWidth, yPosition, boxWidth - 2, 15, 'FD');
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text(`${duracaoTotal} dias`, margin + boxWidth + 3, yPosition + 6);
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.text('Duração', margin + boxWidth + 3, yPosition + 11);
// Box 3
doc.rect(margin + boxWidth * 2, yPosition, boxWidth - 2, 15, 'FD');
doc.setFontSize(8);
doc.setFont('helvetica', 'bold');
doc.text(`${format(dataInicioTotal, 'dd/MM', { locale: ptBR })} - ${format(dataFimTotal, 'dd/MM', { locale: ptBR })}`, margin + boxWidth * 2 + 3, yPosition + 6);
doc.setFont('helvetica', 'normal');
doc.text('Período', margin + boxWidth * 2 + 3, yPosition + 11);
yPosition += 22;
// TABELA COMPACTA
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
doc.rect(margin, yPosition, usableWidth, 8, 'F');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('CRONOGRAMA DETALHADO', margin + 3, yPosition + 5);
yPosition += 10;
// Cabeçalho da tabela - altura reduzida
const colWidths = [usableWidth * 0.4, usableWidth * 0.2, usableWidth * 0.2, usableWidth * 0.2];
const headers = ['Processo', 'Início', 'Fim', 'Duração'];
doc.setFillColor(245, 245, 245);
doc.rect(margin, yPosition, usableWidth, 7, 'F');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.text('RELATÓRIO EXECUTIVO DE CRONOGRAMA DE PRODUÇÃO', headerTextX, 19);
let xPosition = margin;
headers.forEach((header, index) => {
doc.text(header, xPosition + 2, yPosition + 5);
xPosition += colWidths[index];
});
// Badges no canto direito do cabeçalho
const badgeY = 8;
const badgeRight = pageWidth - margin;
yPosition += 7;
// Linhas da tabela - altura reduzida em 40% (de 12 para 7.2)
doc.setFont('helvetica', 'normal');
// Pill de OF
const ofNum = cronograma.ordem_fabricacao?.num_of || 'N/A';
doc.setFillColor(cSecondary[0], cSecondary[1], cSecondary[2]);
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
doc.roundedRect(badgeRight - 65, badgeY, 35, 12, 2, 2, 'FD');
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
doc.setFontSize(8);
doc.setFont('helvetica', 'bold');
doc.text(`OF: ${ofNum}`, badgeRight - 47.5, badgeY + 7.5, { align: 'center' });
cronograma.processos
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
.forEach((processo, index) => {
// Alternância de cores
if (index % 2 === 0) {
doc.setFillColor(250, 250, 250);
doc.rect(margin, yPosition, usableWidth, 7, 'F');
// Pill de Revisão
doc.setFillColor(cBgCard[0], cBgCard[1], cBgCard[2]);
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
doc.roundedRect(badgeRight - 27, badgeY, 27, 12, 2, 2, 'FD');
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
doc.text(`REV: ${cronograma.revisao || 1}`, badgeRight - 13.5, badgeY + 7.5, { align: 'center' });
let yPos = headerHeight + 8;
// ----------------------------------------------------
// 2. CARDS DE RESUMO / KPIS
// ----------------------------------------------------
const processos = (cronograma.processos || []).sort((a, b) => (a.ordem || 0) - (b.ordem || 0));
// Cálculo das datas gerais
let dataInicioTotal = new Date();
let dataFimTotal = new Date();
let duracaoTotal = 0;
if (processos.length > 0) {
const datasInicio = processos.map(p => parseISO(p.data_inicio).getTime()).filter(t => !isNaN(t));
const datasFim = processos.map(p => parseISO(p.data_fim).getTime()).filter(t => !isNaN(t));
if (datasInicio.length > 0 && datasFim.length > 0) {
dataInicioTotal = new Date(Math.min(...datasInicio));
dataFimTotal = new Date(Math.max(...datasFim));
duracaoTotal = differenceInDays(dataFimTotal, dataInicioTotal) + 1;
}
}
xPosition = margin;
const rowData = [
processo.nome_processo.length > 25 ? processo.nome_processo.substring(0, 25) + '...' : processo.nome_processo,
format(parseISO(processo.data_inicio), 'dd/MM', { locale: ptBR }),
format(parseISO(processo.data_fim), 'dd/MM', { locale: ptBR }),
`${calcularDiasCorridos(processo.data_inicio, processo.data_fim)}d`
const cardGap = 5;
const cardWidth = (usableWidth - (cardGap * 3)) / 4;
const cardHeight = 18;
const cardsData = [
{
title: 'ESTRUTURA / OF',
value: `${ofNum} - ${cronograma.ordem_fabricacao?.descritivo || 'Sem descrição'}`
},
{
title: 'GESTOR / RESPONSÁVEL',
value: cronograma.gestor_profile?.full_name || 'Não atribuído'
},
{
title: 'DURAÇÃO & PROCESSO',
value: `${duracaoTotal} Dias Corridos (${processos.length} etapas)`
},
{
title: 'PERÍODO PREVISTO',
value: `${format(dataInicioTotal, 'dd/MM/yyyy')} a ${format(dataFimTotal, 'dd/MM/yyyy')}`
}
];
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
rowData.forEach((data, colIndex) => {
doc.text(data, xPosition + 2, yPosition + 5);
xPosition += colWidths[colIndex];
});
cardsData.forEach((card, idx) => {
const cardX = margin + idx * (cardWidth + cardGap);
yPosition += 7;
});
// Card Container
doc.setFillColor(cBgCard[0], cBgCard[1], cBgCard[2]);
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
doc.roundedRect(cardX, yPos, cardWidth, cardHeight, 1.5, 1.5, 'FD');
yPosition += 10;
// Top Accent Line
doc.setFillColor(cAccent[0], cAccent[1], cAccent[2]);
doc.rect(cardX, yPos, cardWidth, 1, 'F');
// GRÁFICO DE GANTT VISUAL MELHORADO
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
doc.rect(margin, yPosition, usableWidth, 8, 'F');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('LINHA DO TEMPO VISUAL', margin + 3, yPosition + 5);
yPosition += 12;
// Escala de tempo
doc.setTextColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
doc.setFontSize(8);
doc.text(format(dataInicioTotal, 'dd/MM/yy', { locale: ptBR }), margin, yPosition - 2);
doc.text(format(dataFimTotal, 'dd/MM/yy', { locale: ptBR }), margin + usableWidth - 20, yPosition - 2);
doc.text(`${duracaoTotal} dias`, margin + usableWidth/2 - 10, yPosition - 2);
// Linha de base
doc.setDrawColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
doc.line(margin, yPosition, margin + usableWidth, yPosition);
yPosition += 3;
// Barras dos processos - área reservada para nomes maior
const cores = [
[52, 152, 219], // Azul
[46, 204, 113], // Verde
[241, 196, 15], // Amarelo
[155, 89, 182], // Roxo
[231, 76, 60], // Vermelho
[230, 126, 34], // Laranja
[26, 188, 156], // Turquesa
[127, 140, 141] // Cinza
];
const nomeAreaWidth = 60; // Área reservada para nomes dos processos
const graficoWidth = usableWidth - nomeAreaWidth - 5;
cronograma.processos
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
.forEach((processo, index) => {
const diasDoInicio = differenceInDays(parseISO(processo.data_inicio), dataInicioTotal);
const duracaoProcesso = calcularDiasCorridos(processo.data_inicio, processo.data_fim);
const barraInicio = margin + nomeAreaWidth + (diasDoInicio / duracaoTotal) * graficoWidth;
const barraLargura = Math.max(2, (duracaoProcesso / duracaoTotal) * graficoWidth);
// Nome do processo na área reservada
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
// Card Title
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
doc.setFontSize(7);
doc.setFont('helvetica', 'normal');
const nomeProcesso = processo.nome_processo.length > 20 ?
processo.nome_processo.substring(0, 20) + '...' :
processo.nome_processo;
doc.text(nomeProcesso, margin, yPosition + 2);
// Barra colorida
const cor = cores[index % cores.length];
doc.setFillColor(cor[0], cor[1], cor[2]);
doc.rect(barraInicio, yPosition - 1, barraLargura, 6, 'F');
// Duração na barra (se houver espaço)
if (barraLargura > 8) {
doc.setTextColor(255, 255, 255);
doc.setFontSize(6);
doc.setFont('helvetica', 'bold');
doc.text(`${duracaoProcesso}d`, barraInicio + barraLargura/2 - 2, yPosition + 2);
doc.text(card.title, cardX + 3, yPos + 5.5);
// Card Value
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
doc.setFontSize(8.5);
doc.setFont('helvetica', 'bold');
const textTruncated = card.value.length > 32 ? card.value.substring(0, 32) + '...' : card.value;
doc.text(textTruncated, cardX + 3, yPos + 12.5);
});
yPos += cardHeight + 8;
// ----------------------------------------------------
// 3. ESTRUTURA PRINCIPAL: TABELA (ESQUERDA) + GANTT (DIREITA)
// ----------------------------------------------------
const tableWidth = 110; // Tabela ocupa 110mm
const ganttWidth = usableWidth - tableWidth - 6; // Gantt ocupa o restante (~157mm)
const ganttX = margin + tableWidth + 6;
// TÍTULOS DAS SEÇÕES
doc.setFillColor(cSecondary[0], cSecondary[1], cSecondary[2]);
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
doc.roundedRect(margin, yPos, tableWidth, 7, 1, 1, 'FD');
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
doc.setFontSize(8.5);
doc.setFont('helvetica', 'bold');
doc.text('ETAPAS E CRONOGRAMA DETALHADO', margin + 4, yPos + 4.8);
doc.setFillColor(cSecondary[0], cSecondary[1], cSecondary[2]);
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
doc.roundedRect(ganttX, yPos, ganttWidth, 7, 1, 1, 'FD');
doc.text('VISUALIZAÇÃO DE LINHA DO TEMPO (GANTT)', ganttX + 4, yPos + 4.8);
yPos += 9;
// --- TABELA DE PROCESSOS ---
const colWidths = [10, 48, 20, 20, 12]; // Total: 110mm
const headers = ['#', 'Processo / Etapa', 'Início', 'Fim', 'Dias'];
doc.setFillColor(cBgCard[0], cBgCard[1], cBgCard[2]);
doc.rect(margin, yPos, tableWidth, 6, 'F');
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
doc.line(margin, yPos + 6, margin + tableWidth, yPos + 6);
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
doc.setFontSize(7.5);
doc.setFont('helvetica', 'bold');
let colX = margin;
headers.forEach((h, i) => {
const align = i >= 2 ? 'center' : 'left';
const textPosX = align === 'center' ? colX + colWidths[i] / 2 : colX + 2;
doc.text(h, textPosX, yPos + 4.2, { align });
colX += colWidths[i];
});
let tableY = yPos + 6;
const rowHeight = 7.5;
// --- GRÁFICO GANTT (CABEÇALHO DE DATAS) ---
doc.setFillColor(cBgCard[0], cBgCard[1], cBgCard[2]);
doc.rect(ganttX, yPos, ganttWidth, 6, 'F');
doc.line(ganttX, yPos + 6, ganttX + ganttWidth, yPos + 6);
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
doc.setFontSize(7);
doc.setFont('helvetica', 'bold');
doc.text(format(dataInicioTotal, 'dd/MM'), ganttX + 2, yPos + 4.2);
doc.text(format(dataFimTotal, 'dd/MM'), ganttX + ganttWidth - 2, yPos + 4.2, { align: 'right' });
doc.text(`Total: ${duracaoTotal}d`, ganttX + ganttWidth / 2, yPos + 4.2, { align: 'center' });
// Paleta Elegante para as Barras de Gantt (Tons de Cinza/Escuros para impressão monocromática)
const barColors = [
[71, 85, 105], // Slate 600
[51, 65, 85], // Slate 700
[100, 116, 139], // Slate 500
[15, 23, 42] // Slate 900
];
// RENDERIZAR LINHAS DA TABELA E BARRAS GANTT
processos.forEach((proc, idx) => {
// Alternância de cor da linha na tabela
if (idx % 2 === 0) {
doc.setFillColor(255, 255, 255);
} else {
doc.setFillColor(248, 250, 252);
}
doc.rect(margin, tableY, tableWidth, rowHeight, 'F');
// Borda inferior da linha
doc.setDrawColor(241, 245, 249);
doc.line(margin, tableY + rowHeight, margin + tableWidth, tableY + rowHeight);
// Dados da tabela
doc.setFontSize(7.5);
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
let cX = margin;
// Index #
doc.setFont('helvetica', 'bold');
doc.text(`${idx + 1}`, cX + colWidths[0] / 2, tableY + 5, { align: 'center' });
cX += colWidths[0];
// Nome do Processo
doc.setFont('helvetica', 'normal');
const nomeProc = proc.nome_processo.length > 24 ? proc.nome_processo.substring(0, 24) + '...' : proc.nome_processo;
doc.text(nomeProc, cX + 2, tableY + 5);
cX += colWidths[1];
// Data Início
const dIncStr = format(parseISO(proc.data_inicio), 'dd/MM/yy');
doc.text(dIncStr, cX + colWidths[2] / 2, tableY + 5, { align: 'center' });
cX += colWidths[2];
// Data Fim
const dFimStr = format(parseISO(proc.data_fim), 'dd/MM/yy');
doc.text(dFimStr, cX + colWidths[3] / 2, tableY + 5, { align: 'center' });
cX += colWidths[3];
// Duração em Dias
const diasProc = calcularDiasCorridos(proc.data_inicio, proc.data_fim);
doc.setFont('helvetica', 'bold');
doc.text(`${diasProc}d`, cX + colWidths[4] / 2, tableY + 5, { align: 'center' });
// --- LINHA E BARRA DO GANTT ---
// Fundo e linha de grade do Gantt
if (idx % 2 === 0) {
doc.setFillColor(255, 255, 255);
} else {
doc.setFillColor(248, 250, 252);
}
doc.rect(ganttX, tableY, ganttWidth, rowHeight, 'F');
doc.setDrawColor(241, 245, 249);
doc.line(ganttX, tableY + rowHeight, ganttX + ganttWidth, tableY + rowHeight);
// Calcular posição da barra Gantt proporcional
const procStart = parseISO(proc.data_inicio).getTime();
const offsetDays = Math.max(0, differenceInDays(new Date(procStart), dataInicioTotal));
const pxPerDay = ganttWidth / (duracaoTotal || 1);
const barX = ganttX + (offsetDays * pxPerDay);
const barW = Math.max(4, diasProc * pxPerDay);
// Desenhar Barra Arredondada Modern
const color = barColors[idx % barColors.length];
doc.setFillColor(color[0], color[1], color[2]);
doc.roundedRect(barX, tableY + 1.5, barW, rowHeight - 3, 1.2, 1.2, 'F');
// Texto dentro ou ao lado da barra
doc.setTextColor(cWhite[0], cWhite[1], cWhite[2]);
doc.setFontSize(6.5);
doc.setFont('helvetica', 'bold');
if (barW >= 15) {
doc.text(`${diasProc}d`, barX + (barW / 2), tableY + 5, { align: 'center' });
} else {
// Se a barra for pequena, desenha a tag fora
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
doc.text(`${diasProc}d`, barX + barW + 2, tableY + 5);
}
yPosition += 8;
tableY += rowHeight;
});
// RODAPÉ COMPACTO
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F');
// Borda ao redor das caixas
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
doc.rect(margin, yPos + 6, tableWidth, (processos.length + 1) * rowHeight, 'S');
doc.rect(ganttX, yPos + 6, ganttWidth, (processos.length + 1) * rowHeight, 'S');
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
doc.setFontSize(7);
// ----------------------------------------------------
// 4. RODAPÉ EXECUTIVO (FOOTER)
// ----------------------------------------------------
const footerY = pageHeight - 10;
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
doc.line(margin, footerY - 3, pageWidth - margin, footerY - 3);
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
doc.setFontSize(7.5);
doc.setFont('helvetica', 'normal');
doc.text(`${brandSettings.company_name} - Sistema de Gestão`, margin, pageHeight - 8);
doc.text(`${format(new Date(), 'dd/MM/yyyy HH:mm')}`, pageWidth - 35, pageHeight - 8);
// Download do PDF
const nomeArquivo = `cronograma_${cronograma.ordem_fabricacao?.num_of}_rev${cronograma.revisao}_${format(new Date(), 'ddMMyyyy')}.pdf`;
doc.text(
`${brandSettings.company_name || 'TrackSteel'} — Sistema Integrado de Gestão Estrutural`,
margin,
footerY + 1
);
const nowFormatted = format(new Date(), 'dd/MM/yyyy HH:mm', { locale: ptBR });
doc.text(
`Documento Gerado em ${nowFormatted} | Página 1 de 1`,
pageWidth - margin,
footerY + 1,
{ align: 'right' }
);
// Download automático do PDF
const safeOF = (cronograma.ordem_fabricacao?.num_of || 'OF').replace(/[^a-zA-Z0-9_-]/g, '');
const nomeArquivo = `cronograma_${safeOF}_rev${cronograma.revisao || 1}_${format(new Date(), 'ddMMyyyy')}.pdf`;
doc.save(nomeArquivo);
if (onComplete) {
onComplete();
}
};
}, [cronograma, onComplete, brandSettings]);
useEffect(() => {
const timer = setTimeout(() => {
@@ -287,7 +392,7 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
}, 100);
return () => clearTimeout(timer);
}, [cronograma, onComplete, brandSettings]);
}, [gerarPDF]);
return null;
};
@@ -1,9 +1,18 @@
import React, { useMemo } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { format, parseISO, eachDayOfInterval } from 'date-fns';
import React, { useState, useMemo } from 'react';
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip
} from 'recharts';
import { format, parseISO } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
import { Button } from '@/components/ui/button';
import { Scale, Percent, FilterX } from 'lucide-react';
interface GraficoMestreProps {
processos: DashboardProcesso[];
@@ -11,126 +20,324 @@ interface GraficoMestreProps {
processoSelecionado?: string | null;
}
// Ordem sequencial padrão de produção para ordenação linear
const ORDEM_PROCESSOS_LINEAR = [
'detalhamento',
'corte',
'solda',
'pintura/galv',
'pintura',
'galvanizacao',
'expedicao',
'expedição',
'montagem',
'aceite/db',
'aceite',
'concluido',
'concluído'
];
// Paleta de cores vibrantes e distintas para cada processo
const CORES_PROCESSOS: Record<string, string> = {
'detalhamento': '#a855f7', // Roxo / Purple
'corte': '#3b82f6', // Azul Royal
'solda': '#f97316', // Laranja Vibrante
'pintura/galv': '#ec4899', // Rosa / Magenta
'pintura': '#ec4899', // Rosa
'galvanizacao': '#ec4899', // Rosa
'expedicao': '#06b6d4', // Ciano
'expedição': '#06b6d4', // Ciano
'montagem': '#14b8a6', // Verde Água / Teal
'aceite/db': '#6366f1', // Índigo
'aceite': '#6366f1', // Índigo
'concluido': '#10b981', // Verde Esmeralda
'concluído': '#10b981' // Verde Esmeralda
};
// Paleta de fallback se houver processo fora do padrão
const CORES_FALLBACK = [
'#a855f7', '#3b82f6', '#f97316', '#ec4899',
'#06b6d4', '#14b8a6', '#6366f1', '#10b981', '#eab308'
];
interface CustomTooltipProps {
active?: boolean;
payload?: Array<{
name: string;
value: number;
color: string;
dataKey: string;
}>;
label?: string;
modoExibicao: 'kg' | 'percent';
selectedProcesso: string | null;
}
// Componente Tooltip declarado fora da renderização principal para evitar recriação no render
const CustomTooltip: React.FC<CustomTooltipProps> = ({
active,
payload,
label,
modoExibicao,
selectedProcesso
}) => {
if (!active || !payload || !payload.length) return null;
return (
<div className="bg-popover/95 backdrop-blur-md border border-border shadow-2xl rounded-xl p-3 text-popover-foreground min-w-[200px] z-50">
<div className="flex items-center justify-between border-b border-border/50 pb-2 mb-2">
<span className="text-xs font-semibold text-muted-foreground">Data</span>
<span className="text-xs font-bold bg-primary/10 text-primary px-2 py-0.5 rounded-md">{label}</span>
</div>
<div className="space-y-1.5">
{payload.map((entry, index) => {
const isHighlight = !selectedProcesso || selectedProcesso === entry.name;
return (
<div
key={`item-${index}`}
className={`flex items-center justify-between text-xs transition-opacity ${
isHighlight ? 'opacity-100 font-medium' : 'opacity-40'
}`}
>
<div className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-sm"
style={{ backgroundColor: entry.color }}
/>
<span className="truncate max-w-[130px]">{entry.name}</span>
</div>
<span className="font-mono font-semibold ml-2">
{modoExibicao === 'kg'
? `${entry.value.toLocaleString('pt-BR')} kg`
: `${entry.value.toFixed(1)}%`
}
</span>
</div>
);
})}
</div>
</div>
);
};
export const GraficoMestre: React.FC<GraficoMestreProps> = ({
processos,
onProcessoClick,
processoSelecionado
processoSelecionado: processoSelecionadoProp
}) => {
// Processar dados dos gráficos individuais para criar um gráfico sobreposto
const dadosGraficoSobreposto = useMemo(() => {
if (!processos || processos.length === 0) return [];
const [modoExibicao, setModoExibicao] = useState<'kg' | 'percent'>('kg');
const [selectedProcessoInternal, setSelectedProcessoInternal] = useState<string | null>(null);
const selectedProcesso = processoSelecionadoProp !== undefined ? processoSelecionadoProp : selectedProcessoInternal;
const handleSelectProcesso = (nome: string) => {
const nextSelected = selectedProcesso === nome ? null : nome;
setSelectedProcessoInternal(nextSelected);
if (onProcessoClick) {
onProcessoClick(nextSelected || '');
}
};
// 1. Ordenar processos na sequência linear real da produção
const processosOrdenados = useMemo(() => {
if (!processos) return [];
return [...processos].sort((a, b) => {
const nomeA = a.nome.toLowerCase().trim();
const nomeB = b.nome.toLowerCase().trim();
const indexA = ORDEM_PROCESSOS_LINEAR.findIndex(p => nomeA.includes(p));
const indexB = ORDEM_PROCESSOS_LINEAR.findIndex(p => nomeB.includes(p));
const posA = indexA !== -1 ? indexA : 99;
const posB = indexB !== -1 ? indexB : 99;
return posA - posB;
});
}, [processos]);
// Função utilitária para pegar a cor de um processo
const getCorProcesso = (nomeProcesso: string, index: number) => {
const nomeLower = nomeProcesso.toLowerCase().trim();
for (const [key, color] of Object.entries(CORES_PROCESSOS)) {
if (nomeLower.includes(key)) return color;
}
return CORES_FALLBACK[index % CORES_FALLBACK.length];
};
// 2. Processar dados agregados por data
const dadosGrafico = useMemo(() => {
if (!processosOrdenados || processosOrdenados.length === 0) return [];
// Coletar todas as datas dos gráficos individuais
const todasAsDatas = new Set<string>();
processos.forEach(processo => {
processosOrdenados.forEach(processo => {
processo.dadosGrafico.forEach(ponto => {
todasAsDatas.add(ponto.data);
});
});
// Ordenar as datas
const datasOrdenadas = Array.from(todasAsDatas).sort();
const ultimosValoresKg: Record<string, number> = {};
// Construir dados do gráfico sobreposto
return datasOrdenadas.map(data => {
const pontoGrafico: any = {
data: format(parseISO(data), 'dd/MM', { locale: ptBR }),
const dataFormatada = format(parseISO(data), 'dd/MM', { locale: ptBR });
const pontoGrafico: Record<string, string | number> = {
data: dataFormatada,
dataCompleta: data
};
// Para cada processo, buscar o valor realizado na data
processos.forEach(processo => {
const pontoProcesso = processo.dadosGrafico.find(ponto => ponto.data === data);
// Converter para toneladas (dividir por 1000)
pontoGrafico[processo.nome] = pontoProcesso ? Math.round(pontoProcesso.realizado / 1000 * 100) / 100 : 0;
processosOrdenados.forEach(processo => {
const pontoProcesso = processo.dadosGrafico.find(p => p.data === data);
let valorKg: number;
if (pontoProcesso && pontoProcesso.realizado > 0) {
valorKg = pontoProcesso.realizado;
ultimosValoresKg[processo.nome] = valorKg;
} else {
// Se não houver novo ponto ou for 0, mantém o acumulado anterior para a linha NUNCA cair
valorKg = ultimosValoresKg[processo.nome] || 0;
}
if (modoExibicao === 'percent') {
const pesoTotal = processo.pesoTotal > 0 ? processo.pesoTotal : 1;
const percentual = Math.min(100, (valorKg / pesoTotal) * 100);
pontoGrafico[processo.nome] = Math.round(percentual * 10) / 10;
} else {
pontoGrafico[processo.nome] = Math.round(valorKg * 10) / 10;
}
});
return pontoGrafico;
});
}, [processos]);
// Obter cores dos processos baseado no status
const obterCorProcesso = (status: string) => {
switch (status) {
case 'verde':
return '#10b981';
case 'amarelo':
return '#f59e0b';
case 'vermelho':
return '#ef4444';
case 'azul':
return '#3b82f6';
default:
return '#8884d8';
}
};
const handleProcessoClick = (processoNome: string) => {
if (onProcessoClick) {
onProcessoClick(processoNome);
}
};
const formatTooltipValue = (value: number, name: string) => [
`${value.toFixed(2)} t`,
name
];
}, [processosOrdenados, modoExibicao]);
return (
<div className="w-full h-96">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={dadosGraficoSobreposto}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
<div className="space-y-4 w-full">
{/* Barra Superior de Controles */}
<div className="flex flex-wrap items-center justify-between gap-2 bg-muted/40 p-2.5 rounded-lg border border-border/50">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground hidden sm:inline">Métrica:</span>
<div className="flex items-center bg-background border border-border rounded-md p-0.5">
<Button
type="button"
variant={modoExibicao === 'kg' ? 'default' : 'ghost'}
size="sm"
onClick={() => setModoExibicao('kg')}
className="h-7 text-xs px-2.5 gap-1.5"
>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<Scale className="h-3.5 w-3.5" />
<span>Quilogramas (kg)</span>
</Button>
<Button
type="button"
variant={modoExibicao === 'percent' ? 'default' : 'ghost'}
size="sm"
onClick={() => setModoExibicao('percent')}
className="h-7 text-xs px-2.5 gap-1.5"
>
<Percent className="h-3.5 w-3.5" />
<span>Porcentagem (%)</span>
</Button>
</div>
</div>
{selectedProcesso && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleSelectProcesso(selectedProcesso)}
className="h-7 text-xs gap-1 border-dashed"
>
<FilterX className="h-3.5 w-3.5" />
<span>Limpar seleção ({selectedProcesso})</span>
</Button>
)}
</div>
{/* Legenda Customizada em Ordem Linear Sequencial */}
<div className="flex flex-wrap items-center justify-center gap-2 py-1">
{processosOrdenados.map((processo, idx) => {
const color = getCorProcesso(processo.nome, idx);
const isSelected = selectedProcesso === processo.nome;
const isDimmed = selectedProcesso && !isSelected;
return (
<button
key={processo.id}
type="button"
onClick={() => handleSelectProcesso(processo.nome)}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-all cursor-pointer ${
isSelected
? 'bg-primary text-primary-foreground border-primary font-semibold shadow-md scale-105'
: isDimmed
? 'bg-muted/30 text-muted-foreground border-transparent opacity-40 hover:opacity-70'
: 'bg-muted/60 hover:bg-muted text-foreground border-border/60 hover:border-border'
}`}
>
<span
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-sm"
style={{ backgroundColor: color }}
/>
<span>{processo.nome}</span>
<span className="text-[10px] opacity-75 font-mono">
({modoExibicao === 'kg' ? `${Math.round(processo.pesoFabricado)}kg` : `${processo.progressoReal.toFixed(0)}%`})
</span>
</button>
);
})}
</div>
{/* Container de Altura Fixa com minWidth=0 para EVITAR Avisos no Console */}
<div className="w-full h-[380px] min-h-[380px] relative">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<LineChart
data={dadosGrafico}
margin={{ top: 15, right: 25, left: 10, bottom: 25 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border/40" />
<XAxis
dataKey="data"
stroke="#9ca3af"
fontSize={12}
tick={{ fill: '#9ca3af' }}
stroke="currentColor"
fontSize={11}
className="text-muted-foreground"
tickLine={false}
axisLine={{ stroke: 'currentColor', opacity: 0.2 }}
/>
<YAxis
stroke="#9ca3af"
fontSize={12}
tick={{ fill: '#9ca3af' }}
label={{
value: 'Peso Acumulado (t)',
angle: -90,
position: 'insideLeft',
style: { textAnchor: 'middle', fill: '#9ca3af' }
}}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '6px',
color: '#f3f4f6'
}}
formatter={formatTooltipValue}
labelFormatter={(label) => `Data: ${label}`}
/>
<Legend
wrapperStyle={{ color: '#9ca3af' }}
onClick={(e) => handleProcessoClick(e.value)}
stroke="currentColor"
fontSize={11}
className="text-muted-foreground"
tickLine={false}
axisLine={{ stroke: 'currentColor', opacity: 0.2 }}
unit={modoExibicao === 'kg' ? ' kg' : '%'}
tickFormatter={(val) => modoExibicao === 'kg' && val >= 1000 ? `${(val / 1000).toFixed(1)}k` : `${val}`}
/>
<Tooltip content={<CustomTooltip modoExibicao={modoExibicao} selectedProcesso={selectedProcesso} />} />
{processos.map((processo) => (
{processosOrdenados.map((processo, idx) => {
const color = getCorProcesso(processo.nome, idx);
const isSelected = selectedProcesso === processo.nome;
const isDimmed = selectedProcesso && !isSelected;
return (
<Line
key={processo.nome}
key={processo.id}
type="monotone"
dataKey={processo.nome}
stroke={obterCorProcesso(processo.status)}
strokeWidth={2}
dot={{ fill: obterCorProcesso(processo.status), strokeWidth: 2, r: 3 }}
activeDot={{ r: 5, fill: obterCorProcesso(processo.status) }}
opacity={processoSelecionado ? (processoSelecionado === processo.nome ? 1 : 0.3) : 1}
style={{ cursor: 'pointer' }}
name={processo.nome}
stroke={color}
strokeWidth={isSelected ? 3.5 : isDimmed ? 1 : 2.5}
strokeOpacity={isDimmed ? 0.2 : 1}
dot={isSelected ? { fill: color, r: 4.5, strokeWidth: 2, stroke: '#fff' } : false}
activeDot={{ r: 6, fill: color, stroke: '#fff', strokeWidth: 2 }}
connectNulls
/>
))}
);
})}
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
};
@@ -1,4 +1,3 @@
import React from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
@@ -11,11 +10,16 @@ interface GraficoProgressoIndividualProps {
export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProps> = ({ processos }) => {
const formatTooltipValue = (value: number, name: string) => [
`${(value / 1000).toFixed(2)} t`,
`${Math.round(value).toLocaleString('pt-BR')} kg`,
name === 'planejado' ? 'Planejado' : 'Realizado'
];
const formatAxisValue = (value: number) => `${(value / 1000).toFixed(1)}t`;
const formatAxisValue = (value: number) => {
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}k kg`;
}
return `${Math.round(value)} kg`;
};
const formatDateLabel = (tickItem: string) => {
try {
@@ -55,13 +59,13 @@ export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProp
<h3 className="text-lg font-semibold text-card-foreground">
Progresso - {processo.nome}
</h3>
<div className="ml-auto text-sm text-muted-foreground">
{processo.progressoReal.toFixed(1)}% realizado
<div className="ml-auto text-sm text-muted-foreground font-mono">
{processo.progressoReal.toFixed(1)}% realizado ({Math.round(processo.pesoFabricado)} kg)
</div>
</div>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<div className="h-64 w-full relative min-h-[256px]">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<AreaChart data={processo.dadosGrafico}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
@@ -61,10 +61,10 @@ export const ResumoOF: React.FC<ResumoOFProps> = ({ of, data, loading }) => {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-card-foreground">
{(data.pesoTotalFabricado / 1000).toFixed(2)} t
{data.pesoTotalFabricado.toFixed(2)} kg
</div>
<p className="text-xs text-muted-foreground">
de {(data.tonelagem / 1000).toFixed(2)} t contratadas
de {data.tonelagem.toFixed(2)} kg contratadas
</p>
</CardContent>
</Card>
@@ -83,7 +83,7 @@ export const TabelaResumoProcessos: React.FC<TabelaResumoProcessosProps> = ({ da
{getStatusBadge(processo.status)}
</TableCell>
<TableCell className="text-right py-2">
{(processo.pesoFabricado / 1000).toFixed(3)} t
{processo.pesoFabricado.toFixed(1)} kg
</TableCell>
</TableRow>
))}
+66 -86
View File
@@ -19,14 +19,76 @@ export function UserInfo() {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loginTime, setLoginTime] = useState<string>('00:00:00');
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const loginStartTimeRef = useRef<number>(Date.now());
const loginStartTimeRef = useRef<number>(0);
// Resetar tempo de login apenas em eventos reais de login
function resetLoginTime() {
// Limpar interval anterior se existir
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Definir novo tempo de início
const currentTime = Date.now();
loginStartTimeRef.current = currentTime;
localStorage.setItem('userLoginTime', currentTime.toString());
// Iniciar novo cronômetro
intervalRef.current = setInterval(() => {
const elapsed = Date.now() - loginStartTimeRef.current;
const hours = Math.floor(elapsed / (1000 * 60 * 60));
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
}, 1000);
}
useEffect(() => {
async function fetchUserProfile() {
if (!user) return;
const { data: profileArr, error } = await supabase
.from('profiles')
.select('full_name, email, profile_image_url')
.eq('id', user.id)
.limit(1);
if (!error && profileArr && profileArr.length > 0) {
setProfile(profileArr[0]);
} else {
setProfile({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
full_name: (user as any).name || (user as any).username || null,
email: user.email || null,
profile_image_url: null
});
}
}
function initializeLoginTime() {
const savedLoginTime = localStorage.getItem('userLoginTime');
if (savedLoginTime) {
loginStartTimeRef.current = parseInt(savedLoginTime);
} else {
const currentTime = Date.now();
loginStartTimeRef.current = currentTime;
localStorage.setItem('userLoginTime', currentTime.toString());
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
intervalRef.current = setInterval(() => {
const elapsed = Date.now() - loginStartTimeRef.current;
const hours = Math.floor(elapsed / (1000 * 60 * 60));
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
}, 1000);
}
if (user) {
fetchUserProfile();
initializeLoginTime(); // Inicializar timer baseado no localStorage
initializeLoginTime();
} else {
// Limpar timer quando não há usuário
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
@@ -46,97 +108,15 @@ export function UserInfo() {
return () => subscription.unsubscribe();
}, []);
// Inicializar tempo de login baseado no localStorage (preserva tempo entre navegações)
const initializeLoginTime = () => {
const savedLoginTime = localStorage.getItem('userLoginTime');
if (savedLoginTime) {
// Usar tempo salvo do localStorage
loginStartTimeRef.current = parseInt(savedLoginTime);
} else {
// Se não há tempo salvo, definir tempo atual (primeira vez)
const currentTime = Date.now();
loginStartTimeRef.current = currentTime;
localStorage.setItem('userLoginTime', currentTime.toString());
}
// Limpar interval anterior se existir
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Iniciar cronômetro
intervalRef.current = setInterval(() => {
const elapsed = Date.now() - loginStartTimeRef.current;
const hours = Math.floor(elapsed / (1000 * 60 * 60));
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
}, 1000);
};
// Resetar tempo de login apenas em eventos reais de login
const resetLoginTime = () => {
// Limpar interval anterior se existir
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Definir novo tempo de início
const currentTime = Date.now();
loginStartTimeRef.current = currentTime;
localStorage.setItem('userLoginTime', currentTime.toString());
// Iniciar novo cronômetro
intervalRef.current = setInterval(() => {
const elapsed = Date.now() - loginStartTimeRef.current;
const hours = Math.floor(elapsed / (1000 * 60 * 60));
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
}, 1000);
};
// Limpar tempo de login quando usuário sair
// Limpe o interval quando desmontado
useEffect(() => {
const handleBeforeUnload = () => {
localStorage.removeItem('userLoginTime');
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
const fetchUserProfile = async () => {
if (!user) return;
const { data, error } = await supabase
.from('profiles')
.select('full_name, email, profile_image_url')
.eq('id', user.id)
.single();
if (!error && data) {
setProfile(data);
} else {
// Fallback para dados do usuário auth
setProfile({
full_name: user.user_metadata?.full_name || null,
email: user.email || null,
profile_image_url: null
});
}
};
if (!user || !profile) return null;
return (
+73 -19
View File
@@ -104,8 +104,8 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
peca_id,
quantidade_produzida,
processo_id,
processos_fabricacao!inner(nome),
pecas!inner(
processo:processos_fabricacao!inner!apontamentos_producao_processo_id_fkey(nome),
peca:pecas!inner!apontamentos_producao_peca_id_fkey(
id,
marca,
descricao,
@@ -115,43 +115,80 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
of_number
)
`)
.eq('pecas.of_number', romaneio.of_number)
.eq('processos_fabricacao.nome', processo);
.eq('peca.of_number', romaneio.of_number)
.eq('processo.nome', processo);
if (error) {
console.error('Erro ao buscar peças disponíveis:', error);
return;
}
// Agrupar por peça e calcular quantidade disponível
// 2. Buscar quanto dessa OF já foi embalado em TODOS os romaneios da OF
const { data: expedidosData, error: expError } = await supabase
.from('itens_romaneio_pecas')
.select(`
peca_id,
quantidade_expedida,
romaneio:romaneios_expedicao!inner!itens_romaneio_pecas_romaneio_id_fkey(of_number)
`)
.eq('romaneio.of_number', romaneio.of_number);
const expedidosMap = new Map<string, number>();
if (!expError && expedidosData) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(expedidosData as any[]).forEach((item: any) => {
const pid = item.peca_id;
const qtd = item.quantidade_expedida || 0;
const current = expedidosMap.get(pid) || 0;
expedidosMap.set(pid, current + qtd);
});
}
// Agrupar por peça e calcular quantidade total produzida
const pecasAgrupadas = new Map<string, PecaDisponivel>();
apontamentosData?.forEach(apontamento => {
const pecaId = apontamento.pecas.id;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(apontamentosData as any[])?.forEach((apontamento: any) => {
const pecaId = apontamento.peca?.id;
if (!pecaId) return;
const quantidade = apontamento.quantidade_produzida || 0;
if (pecasAgrupadas.has(pecaId)) {
const pecaExistente = pecasAgrupadas.get(pecaId)!;
pecaExistente.quantidade_disponivel += quantidade;
pecaExistente.quantidade_disponivel += quantidade; // Aqui salva o Bruto temporariamente
} else {
pecasAgrupadas.set(pecaId, {
id: apontamento.pecas.id,
marca: apontamento.pecas.marca,
descricao: apontamento.pecas.descricao || '',
etapa_fase: apontamento.pecas.etapa_fase || '',
quantidade_disponivel: quantidade,
peso_unitario: apontamento.pecas.peso_unitario || 0,
prioridade: apontamento.pecas.prioridade || 'P4'
id: apontamento.peca.id,
marca: apontamento.peca.marca,
descricao: apontamento.peca.descricao || '',
etapa_fase: apontamento.peca.etapa_fase || '',
quantidade_disponivel: quantidade, // Bruto temporário
peso_unitario: apontamento.peca.peso_unitario || 0,
prioridade: apontamento.peca.prioridade || 'P4'
});
}
});
const pecasArray = Array.from(pecasAgrupadas.values());
setPecasDisponiveis(pecasArray);
// 4. Calcular o saldo Real (Produzido - Expedido) e limpar zerados
const pecasDisponiveisFinal: PecaDisponivel[] = [];
pecasAgrupadas.forEach((peca, pecaId) => {
const qtdExpedida = expedidosMap.get(pecaId) || 0;
const saldoReal = peca.quantidade_disponivel - qtdExpedida;
if (saldoReal > 0) {
pecasDisponiveisFinal.push({
...peca,
quantidade_disponivel: saldoReal // Retorna apenas o saldo disponível
});
}
});
setPecasDisponiveis(pecasDisponiveisFinal);
// Extrair fases e marcas únicas
const fasesUnicas = [...new Set(pecasArray.map(p => p.etapa_fase).filter(Boolean))];
const marcasUnicas = [...new Set(pecasArray.map(p => p.marca).filter(Boolean))];
const fasesUnicas = [...new Set(pecasDisponiveisFinal.map(p => p.etapa_fase).filter(Boolean))];
const marcasUnicas = [...new Set(pecasDisponiveisFinal.map(p => p.marca).filter(Boolean))];
setFasesDisponiveis(fasesUnicas);
setMarcasDisponiveis(marcasUnicas);
@@ -180,6 +217,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
if (pecasError) {
console.error('Erro ao carregar peças do romaneio:', pecasError);
} else if (pecasData) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itensPecaFormatados = pecasData.map((item: any) => ({
id: item.id,
peca_id: item.peca_id,
@@ -203,6 +241,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
if (insumosError) {
console.error('Erro ao carregar insumos do romaneio:', insumosError);
} else if (insumosData) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itensInsumoFormatados = insumosData.map((item: any) => ({
id: item.id,
nome: item.descricao,
@@ -225,6 +264,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
if (processoSelecionado && isOpen) {
buscarPecasDisponiveis(processoSelecionado);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [processoSelecionado, isOpen, romaneio.of_number]);
// Definir processo padrão quando abrir o modal
@@ -238,6 +278,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
setAdicionarTodas(false);
carregarItensRomaneio();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// Filtrar peças baseado nos filtros selecionados
@@ -306,6 +347,12 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
try {
const quantidade = parseInt(quantidadePeca);
if (quantidade > pecaSelecionada.quantidade_disponivel) {
toast.error(`Quantidade não autorizada! Saldo disponível é ${pecaSelecionada.quantidade_disponivel}`);
return;
}
const { error } = await supabase
.from('itens_romaneio_pecas')
.insert({
@@ -329,6 +376,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
// Recarregar itens e limpar formulário
carregarItensRomaneio();
if (processoSelecionado) {
buscarPecasDisponiveis(processoSelecionado);
}
setPecaDisponivel('');
setQuantidadePeca('');
setAdicionarTodas(false);
@@ -374,6 +424,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
if (error) throw error;
toast.success('Item removido do romaneio');
carregarItensRomaneio();
if (processoSelecionado) {
buscarPecasDisponiveis(processoSelecionado);
}
} catch (error) {
console.error('Erro ao remover item:', error);
toast.error('Erro ao remover item do romaneio');
@@ -572,6 +625,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
<Input
type="number"
min="1"
max={pecasDisponiveis.find(p => p.id === pecaDisponivel)?.quantidade_disponivel || 1}
value={quantidadePeca}
onChange={(e) => setQuantidadePeca(e.target.value)}
placeholder="Quantidade"
File diff suppressed because it is too large Load Diff
@@ -53,163 +53,69 @@ export const ProcessChartOptimized: React.FC<ProcessChartOptimizedProps> = ({
const normalizedMontagemData = normalizeData(montagemData);
return (
<div style={{ fontFamily: 'Inter, sans-serif' }} className="h-full">
<div
className="flex justify-evenly items-end h-full p-4 pt-6 pb-6 rounded-lg"
style={{ height: '200px' }}
>
{/* Grupo Corte */}
<div className="flex flex-col justify-end items-center h-full flex-1">
<div
className="flex items-end justify-center w-full gap-1"
style={{ height: '120px' }}
>
{normalizedCorteData.map((data, index) => {
const colors = ['#f97316', '#10b981', '#ec4899']; // orange, green, pink
const labels = ['7-15d', 'ult.7d', 'prev.7d'];
return (
<div
key={index}
className="relative transition-all duration-500"
style={{
width: '20px',
height: `${calculateHeight(data.weight)}%`,
backgroundColor: colors[index],
borderRadius: '4px 4px 0 0'
}}
>
<div
className="absolute w-full text-center text-xs font-semibold text-foreground"
style={{
top: '-20px',
fontSize: '10px',
lineHeight: '1.2'
}}
>
{formatValue(data.weight)}
<div style={{ fontFamily: 'Inter, sans-serif' }} className="h-full flex flex-col pt-2 pb-3">
{/* Legenda Global */}
<div className="flex justify-center gap-4 text-[10px] sm:text-xs text-muted-foreground mb-4">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 bg-[#f97316] rounded-sm shadow-sm"></div>
<span className="font-medium">7 a 15 dias</span>
</div>
<div
className="absolute w-full text-center text-foreground whitespace-nowrap"
style={{
bottom: '-18px',
fontSize: '9px'
}}
>
{labels[index]}
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 bg-[#10b981] rounded-sm shadow-sm"></div>
<span className="font-medium">Últimos 7 dias</span>
</div>
</div>
);
})}
</div>
<div
className="mt-4 text-muted-foreground font-medium"
style={{ fontSize: '11px' }}
>
Corte
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 bg-[#ec4899] rounded-sm shadow-sm"></div>
<span className="font-medium">Meta (Próx. 7d)</span>
</div>
</div>
{/* Grupo Solda */}
<div className="flex flex-col justify-end items-center h-full flex-1">
<div
className="flex items-end justify-center w-full gap-1"
style={{ height: '120px' }}
className="flex justify-evenly items-end h-full px-2"
style={{ minHeight: '130px' }}
>
{normalizedSoldaData.map((data, index) => {
{[
{ title: 'Corte', data: normalizedCorteData },
{ title: 'Solda', data: normalizedSoldaData },
{ title: 'Montagem', data: normalizedMontagemData }
].map((group, groupIdx) => (
<div key={groupIdx} className="flex flex-col justify-end items-center h-full flex-1">
<div
className="flex items-end justify-center w-full gap-1.5 sm:gap-2"
style={{ height: '100px' }}
>
{group.data.map((data, index) => {
const colors = ['#f97316', '#10b981', '#ec4899']; // orange, green, pink
const labels = ['7-15d', 'ult.7d', 'prev.7d'];
return (
<div
key={index}
className="relative transition-all duration-500"
className="relative transition-all duration-500 shadow-sm"
style={{
width: '20px',
width: '24px',
height: `${calculateHeight(data.weight)}%`,
backgroundColor: colors[index],
borderRadius: '4px 4px 0 0'
}}
>
<div
className="absolute w-full text-center text-xs font-semibold text-foreground"
className="absolute w-full text-center font-bold text-foreground"
style={{
top: '-20px',
fontSize: '10px',
top: '-22px',
fontSize: '11px',
lineHeight: '1.2'
}}
>
{formatValue(data.weight)}
</div>
<div
className="absolute w-full text-center text-foreground whitespace-nowrap"
style={{
bottom: '-18px',
fontSize: '9px'
}}
>
{labels[index]}
</div>
</div>
);
})}
</div>
<div
className="mt-4 text-muted-foreground font-medium"
style={{ fontSize: '11px' }}
>
Solda
</div>
</div>
{/* Grupo Montagem */}
<div className="flex flex-col justify-end items-center h-full flex-1">
<div
className="flex items-end justify-center w-full gap-1"
style={{ height: '120px' }}
>
{normalizedMontagemData.map((data, index) => {
const colors = ['#f97316', '#10b981', '#ec4899']; // orange, green, pink
const labels = ['7-15d', 'ult.7d', 'prev.7d'];
return (
<div
key={index}
className="relative transition-all duration-500"
style={{
width: '20px',
height: `${calculateHeight(data.weight)}%`,
backgroundColor: colors[index],
borderRadius: '4px 4px 0 0'
}}
>
<div
className="absolute w-full text-center text-xs font-semibold text-foreground"
style={{
top: '-20px',
fontSize: '10px',
lineHeight: '1.2'
}}
>
{formatValue(data.weight)}
</div>
<div
className="absolute w-full text-center text-foreground whitespace-nowrap"
style={{
bottom: '-18px',
fontSize: '9px'
}}
>
{labels[index]}
</div>
</div>
);
})}
</div>
<div
className="mt-4 text-muted-foreground font-medium"
style={{ fontSize: '11px' }}
>
Mont. Obra
<div className="mt-4 text-foreground font-semibold text-xs sm:text-sm border-t border-border pt-2 w-4/5 text-center">
{group.title}
</div>
</div>
))}
</div>
</div>
);
File diff suppressed because it is too large Load Diff
@@ -122,14 +122,9 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
canAutoFix: /^[\d,\.]+$/.test(value.toString())
});
convertedData[field] = 0;
} else {
// Para peso_unitario e peso_total, arredondar para integer
if (field === 'peso_unitario' || field === 'peso_total') {
convertedData[field] = Math.round(numValue);
} else {
convertedData[field] = numValue;
}
}
} else {
convertedData[field] = field.includes('componente') ? 0 : (field === 'quantidade' ? 1 : 0);
}
+632
View File
@@ -0,0 +1,632 @@
import React, { useState, useRef } 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 { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { FileSpreadsheet, Upload, Download, CheckCircle2, AlertCircle, Trash2, ArrowLeft, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import * as XLSX from 'xlsx';
export interface PecaXLSData {
of_number: string;
etapa_fase: string;
marca: string;
descricao: string;
quantidade: number;
peso_unitario: number;
peso_total: number;
tratamento_superficial: string;
material: string;
perfil_principal: string;
tem_componentes: boolean;
comprimento_ref?: number | string;
}
interface ImportarXLSModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImport: (pecas: Omit<PecaXLSData, 'id'>[]) => Promise<void>;
ofDefault?: string;
}
export function ImportarXLSModal({
open,
onOpenChange,
onImport,
ofDefault = '',
}: ImportarXLSModalProps) {
const [file, setFile] = useState<File | null>(null);
const [pecasProcessadas, setPecasProcessadas] = useState<PecaXLSData[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [step, setStep] = useState<'upload' | 'preview'>('upload');
const fileInputRef = useRef<HTMLInputElement>(null);
const resetState = () => {
setFile(null);
setPecasProcessadas([]);
setIsProcessing(false);
setIsImporting(false);
setStep('upload');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleModalOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetState();
}
onOpenChange(newOpen);
};
// Função auxiliar para converter valores para número seguro
const parseNumber = (val: unknown): number => {
if (val === null || val === undefined || val === '') return 0;
if (typeof val === 'number') return isNaN(val) ? 0 : val;
// String: trata "1.413,00" ou "1,413.00" ou "1413"
let str = String(val).trim();
// Se tiver vírgula e ponto, identifica separador decimal
if (str.includes('.') && str.includes(',')) {
if (str.indexOf('.') < str.indexOf(',')) {
// Ex: 1.413,00 (padrão BR)
str = str.replace(/\./g, '').replace(',', '.');
} else {
// Ex: 1,413.00 (padrão US)
str = str.replace(/,/g, '');
}
} else if (str.includes(',')) {
// Ex: 1413,50
str = str.replace(',', '.');
}
const num = parseFloat(str);
return isNaN(num) ? 0 : num;
};
// Parser robusto do arquivo Excel/XLSX/XLS/CSV
const processExcelFile = async (selectedFile: File) => {
setIsProcessing(true);
try {
const data = await selectedFile.arrayBuffer();
const workbook = XLSX.read(data, { type: 'array' });
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
toast.error('O arquivo Excel não contém nenhuma planilha.');
setIsProcessing(false);
return;
}
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// Converte para matriz de linhas (array de arrays) para busca flexível do cabeçalho
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawRows: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' });
if (!rawRows || rawRows.length === 0) {
toast.error('A planilha selecionada está vazia.');
setIsProcessing(false);
return;
}
// 1. Procurar a linha de cabeçalho
let headerRowIndex = -1;
const colMap: { [key: string]: number } = {};
const normalizeStr = (s: unknown) =>
String(s || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/g, '');
for (let r = 0; r < Math.min(rawRows.length, 15); r++) {
const row = rawRows[r];
if (!Array.isArray(row)) continue;
const rowStr = row.map(normalizeStr).join(' ');
if (
rowStr.includes('marca') ||
rowStr.includes('descricao') ||
rowStr.includes('perfil') ||
rowStr.includes('peso') ||
rowStr.includes('quant')
) {
headerRowIndex = r;
break;
}
}
const pecasLidas: PecaXLSData[] = [];
if (headerRowIndex !== -1) {
// Mapeia colunas por nome
const headers = rawRows[headerRowIndex].map((h: unknown) => normalizeStr(h));
headers.forEach((h: string, colIdx: number) => {
if ((h === 'of' || h.includes('trabalho') || h.includes('numof') || h.includes('numeroof')) && colMap['of'] === undefined) colMap['of'] = colIdx;
else if ((h.includes('fase') || h.includes('etapa')) && colMap['fase'] === undefined) colMap['fase'] = colIdx;
else if ((h.includes('marca') || h.includes('posicao') || h.includes('item') || h === 'pos') && colMap['marca'] === undefined) colMap['marca'] = colIdx;
else if ((h.includes('desc') || h.includes('nome')) && colMap['descricao'] === undefined) colMap['descricao'] = colIdx;
else if ((h.includes('comp') && (h.includes('componente') || h.includes('composto') || h.includes('con'))) && colMap['componentes'] === undefined) colMap['componentes'] = colIdx;
else if ((h.includes('quant') || h.includes('qtd')) && colMap['quantidade'] === undefined) colMap['quantidade'] = colIdx;
else if ((h.includes('pesounit') || h.includes('pesodapeca') || (h.includes('unit') && h.includes('peso'))) && colMap['peso_unitario'] === undefined) colMap['peso_unitario'] = colIdx;
else if ((h.includes('pesototal') || h.includes('totalpeso') || (h.includes('total') && h.includes('peso'))) && colMap['peso_total'] === undefined) colMap['peso_total'] = colIdx;
else if ((h.includes('tratam') || h.includes('superf') || h.includes('pintura') || h.includes('acab')) && colMap['tratamento'] === undefined) colMap['tratamento'] = colIdx;
else if ((h.includes('mat') || h.includes('qualidade') || h.includes('aco')) && colMap['material'] === undefined) colMap['material'] = colIdx;
else if ((h.includes('perfil') || h.includes('perfilprinc')) && colMap['perfil_principal'] === undefined) colMap['perfil_principal'] = colIdx;
else if ((h.includes('compriment') || h.includes('comprmm') || h.includes('length')) && colMap['comprimento'] === undefined) colMap['comprimento'] = colIdx;
});
// Itera sobre as linhas de dados após o cabeçalho
for (let r = headerRowIndex + 1; r < rawRows.length; r++) {
const row = rawRows[r];
if (!row || row.length === 0) continue;
// Se a linha estiver totalmente vazia
if (row.every((cell: unknown) => cell === '' || cell === null || cell === undefined)) continue;
const getColVal = (key: string, fallbackIdx?: number) => {
if (colMap[key] !== undefined && row[colMap[key]] !== undefined) {
return row[colMap[key]];
}
if (fallbackIdx !== undefined && row[fallbackIdx] !== undefined) {
return row[fallbackIdx];
}
return '';
};
const rawMarca = String(getColVal('marca', 2)).trim();
const rawDesc = String(getColVal('descricao', 3)).trim();
const rawPerfil = String(getColVal('perfil_principal', 9) || rawDesc).trim();
// Ignora linhas de totalizadores ou sem marca
if (!rawMarca || rawMarca.toLowerCase().includes('total')) continue;
let ofNumber = String(getColVal('of', 0)).trim();
if (!ofNumber) {
ofNumber = ofDefault || 'B132';
}
// Normaliza formato da OF se necessário (ex: "B-129" -> "B129")
ofNumber = ofNumber.replace(/^B-(\d+)/i, 'B$1');
let etapaFase = String(getColVal('fase', 1)).trim();
if (!etapaFase) etapaFase = 'Fabricação';
const quantidade = Math.max(1, Math.round(parseNumber(getColVal('quantidade', 4)) || 1));
let pesoUnit = parseNumber(getColVal('peso_unitario', 5));
let pesoTot = parseNumber(getColVal('peso_total', 6));
if (pesoTot > 0 && pesoUnit === 0) {
pesoUnit = Math.round(pesoTot / quantidade);
} else if (pesoUnit > 0 && pesoTot === 0) {
pesoTot = Math.round(pesoUnit * quantidade);
}
// Tratamento superficial: regra do usuário -> sempre preencher "pintura" por padrão
let tratSuperficial = String(getColVal('tratamento', 7)).trim();
if (!tratSuperficial) {
tratSuperficial = 'pintura';
}
let material = String(getColVal('material', 8)).trim();
if (!material) {
material = 'Aço A36';
}
const rawComp = String(getColVal('componentes', 4)).toLowerCase();
const temComponentes = rawComp === 'sim' || rawComp === 'true' || rawComp === '1';
const comprimentoRef = getColVal('comprimento', 10);
pecasLidas.push({
of_number: ofNumber,
etapa_fase: etapaFase,
marca: rawMarca,
descricao: rawDesc || rawPerfil,
quantidade,
peso_unitario: pesoUnit,
peso_total: pesoTot,
tratamento_superficial: tratSuperficial,
material,
perfil_principal: rawPerfil || rawDesc,
tem_componentes: temComponentes,
comprimento_ref: comprimentoRef || undefined,
});
}
} else {
// Fallback por ordem posicional estrita:
// Coluna 0: OF, 1: Fase, 2: Marca, 3: Descrição, 4: Quantidade, 5: Peso Unit, 6: Peso Total, 7: Tratamento, 8: Material, 9: Perfil Principal
for (let r = 0; r < rawRows.length; r++) {
const row = rawRows[r];
if (!row || row.length < 3) continue;
const rawMarca = String(row[2] || row[0]).trim();
if (!rawMarca || rawMarca.toLowerCase().includes('marca') || rawMarca.toLowerCase().includes('total')) continue;
const ofNumber = String(row[0] || ofDefault || 'B132').trim().replace(/^B-(\d+)/i, 'B$1');
const etapaFase = String(row[1] || 'Fabricação').trim();
const rawDesc = String(row[3] || '').trim();
const quantidade = Math.max(1, Math.round(parseNumber(row[4]) || 1));
const pesoUnit = parseNumber(row[5]);
const pesoTot = parseNumber(row[6]) || (pesoUnit * quantidade);
const tratSuperficial = String(row[7] || 'pintura').trim() || 'pintura';
const material = String(row[8] || 'Aço A36').trim();
const perfilPrincipal = String(row[9] || rawDesc).trim();
pecasLidas.push({
of_number: ofNumber,
etapa_fase: etapaFase,
marca: rawMarca,
descricao: rawDesc || perfilPrincipal,
quantidade,
peso_unitario: pesoUnit,
peso_total: pesoTot,
tratamento_superficial: tratSuperficial,
material,
perfil_principal: perfilPrincipal,
tem_componentes: false,
});
}
}
if (pecasLidas.length === 0) {
toast.error('Nenhuma linha de peça válida foi identificada no arquivo.');
setIsProcessing(false);
return;
}
setPecasProcessadas(pecasLidas);
setStep('preview');
toast.success(`${pecasLidas.length} peça(s) identificada(s) com sucesso no arquivo!`);
} catch (err: unknown) {
console.error('Erro ao processar planilha:', err);
toast.error(`Falha ao ler o arquivo Excel: ${(err as Error).message || 'Formato não suportado'}`);
} finally {
setIsProcessing(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (selected) {
setFile(selected);
processExcelFile(selected);
}
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
setFile(droppedFile);
processExcelFile(droppedFile);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const handleRemovePeca = (index: number) => {
setPecasProcessadas(prev => prev.filter((_, idx) => idx !== index));
};
const handleImportSubmit = async () => {
if (pecasProcessadas.length === 0) {
toast.error('Nenhuma peça para importar.');
return;
}
setIsImporting(true);
try {
const pecasPayload = pecasProcessadas.map(p => ({
of_number: p.of_number,
etapa_fase: p.etapa_fase,
marca: p.marca,
descricao: p.descricao,
quantidade: p.quantidade,
peso_unitario: p.peso_unitario,
peso_total: p.peso_total,
tratamento_superficial: p.tratamento_superficial || 'pintura',
material: p.material,
perfil_principal: p.perfil_principal,
tem_componentes: p.tem_componentes,
}));
await onImport(pecasPayload);
toast.success(`${pecasProcessadas.length} peças importadas com sucesso para a OF!`);
handleModalOpenChange(false);
} catch (err: unknown) {
console.error('Erro ao importar peças do Excel:', err);
toast.error(`Erro ao salvar peças: ${(err as Error).message || 'Falha de comunicação'}`);
} finally {
setIsImporting(false);
}
};
// Gerar e baixar modelo Excel (.xlsx) oficial
const handleDownloadModeloXLS = () => {
const headers = [
'OF',
'Fase',
'Marca',
'Descrição',
'Composto por Componentes',
'Quantidade',
'Peso Unitário (kg)',
'Peso Total (kg)',
'Tratamento Superficial',
'Material',
'Perfil Principal',
'Comprimento Ref. (mm)'
];
const exampleRows = [
[ofDefault || 'B129', '2', '1', 'W 310x38.7', 'SIM', 1, 1413, 1413, 'pintura', 'A572-GR 50', 'W 310x38.7', 3241],
[ofDefault || 'B129', '2', '2', 'W 310x38.7', 'SIM', 1, 2549, 2549, 'pintura', 'A572-GR 50', 'W 310x38.7', 6000],
[ofDefault || 'B129', '2', '3', 'W 310x38.7', 'SIM', 1, 2379, 2379, 'pintura', 'A572-GR 50', 'W 310x38.7', 5780],
[ofDefault || 'B129', '2', '4', 'W 310x38.7', 'SIM', 1, 2287, 2287, 'pintura', 'A572-GR 50', 'W 310x38.7', 5762],
[ofDefault || 'B129', '2', '13', 'W 150x13.0', 'NÃO', 1, 2886, 2886, 'pintura', 'A36', 'W 150x13.0', ''],
[ofDefault || 'B129', '2', '15', 'L3X3X1/4', 'NÃO', 1, 545, 545, 'pintura', 'A36', 'L3X3X1/4', ''],
[ofDefault || 'B129', '2', '20', 'W 150x13.0', 'SIM', 1, 1249, 1249, 'pintura', 'A572-GR 50', 'W 150x13.0', 2841]
];
const ws = XLSX.utils.aoa_to_sheet([headers, ...exampleRows]);
// Ajusta largura das colunas
ws['!cols'] = [
{ wch: 10 }, // OF
{ wch: 8 }, // Fase
{ wch: 10 }, // Marca
{ wch: 18 }, // Descrição
{ wch: 25 }, // Composto por
{ wch: 12 }, // Qtd
{ wch: 18 }, // Peso Unit
{ wch: 16 }, // Peso Total
{ wch: 22 }, // Tratamento
{ wch: 16 }, // Material
{ wch: 18 }, // Perfil Principal
{ wch: 22 } // Comprimento
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Lista de Peças');
XLSX.writeFile(wb, 'modelo_importacao_pecas.xlsx');
toast.success('Modelo Excel (.xlsx) baixado com sucesso!');
};
const totalPesoCalculado = pecasProcessadas.reduce((acc, p) => acc + (p.peso_total || 0), 0);
const totalQuantidade = pecasProcessadas.reduce((acc, p) => acc + (p.quantidade || 0), 0);
return (
<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">
<DialogHeader className="pb-3 border-b border-border">
<DialogTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileSpreadsheet className="h-6 w-6 text-emerald-500" />
<span className="text-xl font-bold">Importar Peças via Planilha Excel (XLS / XLSX)</span>
</div>
{ofDefault && (
<Badge variant="outline" className="text-sm font-semibold border-primary text-primary">
OF Alvo: {ofDefault}
</Badge>
)}
</DialogTitle>
</DialogHeader>
{step === 'upload' ? (
<div className="space-y-6 py-4">
{/* Zona de Drop / Upload */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-border hover:border-emerald-500 rounded-xl p-8 text-center cursor-pointer transition-colors bg-muted/30 hover:bg-muted/60"
>
<div className="flex flex-col items-center justify-center space-y-3">
<div className="p-4 bg-emerald-500/10 text-emerald-500 rounded-full">
<Upload className="h-8 w-8" />
</div>
<div>
<p className="text-base font-semibold text-foreground">
Clique para selecionar ou arraste sua planilha Excel aqui
</p>
<p className="text-sm text-muted-foreground mt-1">
Suporta arquivos <b>.xlsx</b>, <b>.xls</b> e <b>.csv</b>
</p>
</div>
<Button type="button" variant="outline" size="sm" className="mt-2 pointer-events-none">
Selecionar Arquivo
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept=".xlsx, .xls, .csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
onChange={handleFileChange}
className="hidden"
/>
</div>
{isProcessing && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground p-4 bg-muted/20 rounded-lg">
<RefreshCw className="h-4 w-4 animate-spin text-emerald-500" />
<span>Processando e validando linhas da planilha...</span>
</div>
)}
{/* Informações sobre a estrutura */}
<div className="bg-muted/40 rounded-lg p-4 border border-border space-y-2 text-xs text-muted-foreground">
<div className="font-semibold text-foreground text-sm flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
Ordem e Colunas Suportadas:
</div>
<p>
A planilha pode conter as seguintes colunas (na ordem ou por cabeçalho):
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 font-mono text-[11px] pt-1">
<span className="p-1.5 bg-background rounded border border-border">1. OF</span>
<span className="p-1.5 bg-background rounded border border-border">2. Fase</span>
<span className="p-1.5 bg-background rounded border border-border">3. Marca</span>
<span className="p-1.5 bg-background rounded border border-border">4. Descrição</span>
<span className="p-1.5 bg-background rounded border border-border">5. Quantidade</span>
<span className="p-1.5 bg-background rounded border border-border">6. Peso Unitário (kg)</span>
<span className="p-1.5 bg-background rounded border border-border">7. Peso Total (kg)</span>
<span className="p-1.5 bg-background rounded border border-border">8. Tratamento (pintura)</span>
<span className="p-1.5 bg-background rounded border border-border">9. Material (Aço)</span>
<span className="p-1.5 bg-background rounded border border-border">10. Perfil Principal</span>
</div>
<p className="pt-2 text-[11px]">
💡 <i>Dica: O campo de Tratamento Superficial será preenchido automaticamente como <b>"pintura"</b> caso venha em branco.</i>
</p>
</div>
{/* Ações inferiores */}
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDownloadModeloXLS}
className="flex items-center gap-2 text-xs"
>
<Download className="h-4 w-4 text-emerald-600" />
Baixar Modelo Excel (.xlsx)
</Button>
<Button
type="button"
variant="ghost"
onClick={() => handleModalOpenChange(false)}
>
Cancelar
</Button>
</div>
</div>
) : (
/* Step Preview */
<div className="flex-1 flex flex-col space-y-4 py-2 overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-3 bg-muted/40 p-3 rounded-lg border border-border text-sm">
<div className="flex items-center gap-4">
<div>
<span className="text-muted-foreground">Total de Peças:</span>{' '}
<b className="text-foreground text-base">{pecasProcessadas.length}</b> ({totalQuantidade} un.)
</div>
<div>
<span className="text-muted-foreground">Peso Total:</span>{' '}
<b className="text-emerald-500 text-base">
{totalPesoCalculado.toLocaleString('pt-BR')} kg
</b>
<span className="text-xs text-muted-foreground ml-1">
({(totalPesoCalculado / 1000).toFixed(3)} t)
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setStep('upload')}
className="flex items-center gap-1 text-xs"
>
<ArrowLeft className="h-3.5 w-3.5" />
Trocar Arquivo
</Button>
</div>
</div>
{/* Tabela de Preview */}
<ScrollArea className="flex-1 max-h-[380px] border border-border rounded-md">
<Table>
<TableHeader className="bg-muted/60 sticky top-0 z-10">
<TableRow>
<TableHead className="w-16">OF</TableHead>
<TableHead className="w-16">Fase</TableHead>
<TableHead className="w-20">Marca</TableHead>
<TableHead>Descrição</TableHead>
<TableHead className="w-16 text-center">Qtd</TableHead>
<TableHead className="w-24 text-right">Peso Un. (kg)</TableHead>
<TableHead className="w-24 text-right">Peso Total (kg)</TableHead>
<TableHead className="w-24">Tratamento</TableHead>
<TableHead className="w-24">Material</TableHead>
<TableHead className="w-28">Perfil Princ.</TableHead>
<TableHead className="w-12 text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pecasProcessadas.map((peca, idx) => (
<TableRow key={idx} className="hover:bg-muted/30">
<TableCell className="font-semibold text-primary text-xs">{peca.of_number}</TableCell>
<TableCell className="text-xs">{peca.etapa_fase}</TableCell>
<TableCell className="font-bold text-xs">{peca.marca}</TableCell>
<TableCell className="text-xs font-mono">{peca.descricao}</TableCell>
<TableCell className="text-center text-xs font-semibold">{peca.quantidade}</TableCell>
<TableCell className="text-right text-xs">{peca.peso_unitario.toLocaleString('pt-BR')}</TableCell>
<TableCell className="text-right text-xs font-semibold text-emerald-500">
{peca.peso_total.toLocaleString('pt-BR')}
</TableCell>
<TableCell className="text-xs">{peca.tratamento_superficial}</TableCell>
<TableCell className="text-xs">{peca.material}</TableCell>
<TableCell className="text-xs font-mono">{peca.perfil_principal}</TableCell>
<TableCell className="text-center p-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemovePeca(idx)}
className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
<DialogFooter className="pt-3 border-t border-border flex items-center justify-between sm:justify-between w-full">
<Button
type="button"
variant="outline"
onClick={() => handleModalOpenChange(false)}
disabled={isImporting}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleImportSubmit}
disabled={isImporting || pecasProcessadas.length === 0}
className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold flex items-center gap-2"
>
{isImporting ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
Importando {pecasProcessadas.length} Peças...
</>
) : (
<>
<CheckCircle2 className="h-4 w-4" />
Confirmar e Importar {pecasProcessadas.length} Peças
</>
)}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}
+22 -1
View File
@@ -6,13 +6,15 @@ import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card';
import { Upload, FileText, Trash2, Download, Undo2 } from 'lucide-react';
import { Upload, FileText, Trash2, Download, Undo2, FileSpreadsheet } from 'lucide-react';
import { toast } from 'sonner';
import { Peca } from '@/hooks/usePecas';
import { ImportarPecasModal } from './ImportarPecasModal';
import { ImportarXLSModal } from './ImportarXLSModal';
interface PecaFormProps {
ofNumbers: string[];
ofDefault?: string;
onSave: (data: any) => Promise<boolean>;
onUpdate: (id: string, data: any) => Promise<boolean>;
onImportCSV: (file: File) => Promise<boolean>;
@@ -30,6 +32,7 @@ interface PecaFormProps {
export function PecaForm({
ofNumbers,
ofDefault,
onSave,
onUpdate,
onImportCSV,
@@ -92,6 +95,7 @@ export function PecaForm({
const fileInputRef = useRef<HTMLInputElement>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showXLSModal, setShowXLSModal] = useState(false);
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -361,6 +365,16 @@ export function PecaForm({
<Download className="h-4 w-4" />
Download Modelo CSV
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowXLSModal(true)}
className="flex items-center gap-2 border-emerald-600 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30 font-semibold"
>
<FileSpreadsheet className="h-4 w-4 text-emerald-600" />
Importar de XLS
</Button>
</div>
<input
@@ -385,6 +399,13 @@ export function PecaForm({
etapa_fase: peca.etapa_fase
}))}
/>
<ImportarXLSModal
open={showXLSModal}
onOpenChange={setShowXLSModal}
onImport={onImportPecas}
ofDefault={formData.of_number || ofDefault || (ofNumbers && ofNumbers[0]) || ''}
/>
</div>
);
}
+1 -1
View File
@@ -77,7 +77,7 @@ export function TableRow({
</TableCell>
<TableCell className="w-16 px-2 text-right font-medium text-foreground text-xs">
{(peca.quantidade * peca.peso_unitario).toFixed(2)} kg
{(peca.peso_total || (peca.quantidade * peca.peso_unitario)).toFixed(2)} kg
</TableCell>
<TableCell className="w-16 px-2 text-muted-foreground text-xs">
+89 -50
View File
@@ -1,17 +1,19 @@
import React from 'react';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { PrioridadesPDFTemplate } from './PrioridadesPDFTemplate';
import { ItemPrioridade } from '@/hooks/useItensPrioridadeFabricacao';
import { Download } from 'lucide-react';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { Download, Loader2, Printer } from 'lucide-react';
import { generateProfessionalPDF, printProfessionalPDF } from '@/utils/pdfGenerator';
import { toast } from 'sonner';
interface PrioridadesPDFProps {
isOpen: boolean;
onClose: () => void;
itensPorPrioridade: { [key: string]: ItemPrioridade[] };
ofSelecionada?: string | null;
faseSelecionada?: string | null;
versaoAtual?: {
revisao: number;
dataModificacao: string;
@@ -23,52 +25,19 @@ export const PrioridadesPDF: React.FC<PrioridadesPDFProps> = ({
isOpen,
onClose,
itensPorPrioridade,
ofSelecionada,
faseSelecionada,
versaoAtual
}) => {
const handleGerarPDF = async () => {
const elemento = document.getElementById('prioridades-pdf-content');
const [isGenerating, setIsGenerating] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
if (!elemento) {
console.error('Elemento não encontrado');
return;
}
try {
const canvas = await html2canvas(elemento, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff'
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('p', 'mm', 'a4');
const imgWidth = 210;
const pageHeight = 295;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 0;
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
while (heightLeft >= 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
// Extrair OF e Fase para o nome do arquivo
const todosItens = Object.values(itensPorPrioridade).flat();
const getNomeArquivo = () => {
let nomeArquivo = 'checklist-producao';
if (todosItens.length > 0) {
const todosItens = Object.values(itensPorPrioridade).flat();
const primeiroItem = todosItens[0];
const of = primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number;
const fase = primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase;
const of = ofSelecionada || primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number;
const fase = faseSelecionada || primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase;
if (of && fase) {
nomeArquivo = `checklist-producao-${of}-${fase}`;
@@ -76,11 +45,46 @@ export const PrioridadesPDF: React.FC<PrioridadesPDFProps> = ({
nomeArquivo += `-rev${versaoAtual.revisao}`;
}
}
return nomeArquivo;
};
const handleGerarPDF = async () => {
const elemento = document.getElementById('prioridades-pdf-content');
if (!elemento) {
toast.error('Elemento do relatório não encontrado');
return;
}
pdf.save(`${nomeArquivo}.pdf`);
} catch (error) {
try {
setIsGenerating(true);
const nomeArquivo = getNomeArquivo();
await generateProfessionalPDF('prioridades-pdf-content', `${nomeArquivo}.pdf`);
toast.success('PDF baixado com sucesso!');
} catch (error: any) {
console.error('Erro ao gerar PDF:', error);
toast.error(error.message || 'Erro ao gerar PDF');
} finally {
setIsGenerating(false);
}
};
const handleImprimir = async () => {
const elemento = document.getElementById('prioridades-pdf-content');
if (!elemento) {
toast.error('Elemento do relatório não encontrado');
return;
}
try {
setIsPrinting(true);
await printProfessionalPDF('prioridades-pdf-content');
toast.success('Relatório enviado para impressão');
} catch (error: any) {
console.error('Erro ao imprimir:', error);
toast.error('Erro ao imprimir relatório');
} finally {
setIsPrinting(false);
}
};
@@ -88,18 +92,53 @@ export const PrioridadesPDF: React.FC<PrioridadesPDFProps> = ({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
Visualizar Checklist de Produção
<Button onClick={handleGerarPDF} className="ml-4">
<DialogTitle className="flex flex-wrap items-center justify-between gap-2">
<span>Visualizar Checklist de Produção</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleImprimir}
disabled={isPrinting || isGenerating}
>
{isPrinting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Imprimindo...
</>
) : (
<>
<Printer className="h-4 w-4 mr-2" />
Imprimir
</>
)}
</Button>
<Button
size="sm"
onClick={handleGerarPDF}
disabled={isGenerating || isPrinting}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Gerando PDF...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Baixar PDF
</>
)}
</Button>
</div>
</DialogTitle>
</DialogHeader>
<div className="mt-4">
<PrioridadesPDFTemplate
itensPorPrioridade={itensPorPrioridade}
ofSelecionada={ofSelecionada}
faseSelecionada={faseSelecionada}
versaoAtual={versaoAtual}
/>
</div>
@@ -4,6 +4,8 @@ import { ItemPrioridade } from '@/hooks/useItensPrioridadeFabricacao';
interface PrioridadesPDFTemplateProps {
itensPorPrioridade: { [key: string]: ItemPrioridade[] };
ofSelecionada?: string | null;
faseSelecionada?: string | null;
versaoAtual?: {
revisao: number;
dataModificacao: string;
@@ -13,13 +15,15 @@ interface PrioridadesPDFTemplateProps {
export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
itensPorPrioridade,
ofSelecionada,
faseSelecionada,
versaoAtual
}) => {
const todosItens = Object.values(itensPorPrioridade).flat();
const primeiroItem = todosItens[0];
const ofNumber = primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number || 'N/A';
const etapaFase = primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase || 'N/A';
const ofNumber = ofSelecionada || primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number || 'N/A';
const etapaFase = faseSelecionada || primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase || 'N/A';
const dataAtual = new Date().toLocaleDateString('pt-BR');
@@ -35,11 +39,11 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
const getCoresPrioridade = (codigo: string) => {
switch (codigo) {
case 'P1': return 'text-red-700 bg-red-100';
case 'P2': return 'text-orange-700 bg-orange-100';
case 'P3': return 'text-blue-700 bg-blue-100';
case 'P4': return 'text-gray-700 bg-gray-200';
default: return 'text-gray-700 bg-gray-200';
case 'P1': return 'text-red-700 bg-red-100 border-red-300';
case 'P2': return 'text-orange-700 bg-orange-100 border-orange-300';
case 'P3': return 'text-blue-700 bg-blue-100 border-blue-300';
case 'P4': return 'text-gray-700 bg-gray-200 border-gray-300';
default: return 'text-gray-700 bg-gray-200 border-gray-300';
}
};
@@ -50,66 +54,83 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
const numBigBoxes = Math.floor(quantity / 5);
const numSmallBoxes = quantity % 5;
// Quadrados grandes com "5"
// Quadrados com "5"
for (let i = 0; i < numBigBoxes; i++) {
boxes.push(
<div key={`big-${i}`} className="tick-box-large">
<span>5</span>
</div>
<svg
key={`big-${i}`}
width="13"
height="13"
viewBox="0 0 13 13"
style={{ display: 'inline-block', verticalAlign: '-1px', marginRight: '2px' }}
>
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#f3f4f6" stroke="#4b5563" strokeWidth="1" />
<text x="6.5" y="9.5" textAnchor="middle" fontSize="8.5" fontFamily="Arial, sans-serif" fontWeight="bold" fill="#4b5563">5</text>
</svg>
);
}
// Quadrados pequenos restantes
// Quadrados unitários restantes
for (let i = 0; i < numSmallBoxes; i++) {
boxes.push(<div key={`small-${i}`} className="tick-box"></div>);
boxes.push(
<svg
key={`small-${i}`}
width="13"
height="13"
viewBox="0 0 13 13"
style={{ display: 'inline-block', verticalAlign: '-1px', marginRight: '2px' }}
>
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1" />
</svg>
);
}
} else {
// Apenas quadrados pequenos
// Apenas quadrados unitários
for (let i = 0; i < quantity; i++) {
boxes.push(<div key={i} className="tick-box"></div>);
boxes.push(
<svg
key={i}
width="13"
height="13"
viewBox="0 0 13 13"
style={{ display: 'inline-block', verticalAlign: '-1px', marginRight: '2px' }}
>
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1" />
</svg>
);
}
}
return <div className="flex items-center flex-wrap gap-1">{boxes}</div>;
return <span style={{ display: 'inline-block', verticalAlign: 'middle', marginLeft: '4px' }}>{boxes}</span>;
};
return (
<div id="prioridades-pdf-content" className="bg-white text-black max-w-4xl mx-auto p-6">
<style>{`
.tick-box {
width: 12px;
height: 12px;
border: 1px solid #6b7280;
display: inline-block;
flex-shrink: 0;
}
.tick-box-large {
width: 16px;
height: 16px;
border: 1px solid #6b7280;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
}
.tick-box-large span {
color: #d1d5db;
font-size: 10px;
font-weight: 600;
.checklist-container {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #111827;
}
.item-card {
border: 1px solid #e5e7eb;
padding: 8px;
padding: 8px 10px;
border-radius: 6px;
background-color: #ffffff;
box-sizing: border-box;
}
.item-signature {
margin-top: 6px;
font-size: 11px;
color: #4b5563;
border-bottom: 1px solid #9ca3af;
padding-bottom: 2px;
height: 18px;
line-height: 14px;
}
@media print {
body {
font-size: 9px;
}
.check-box-print {
border: 1px solid #333 !important;
}
.page-break {
page-break-before: always;
}
@@ -122,43 +143,61 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
}
`}</style>
<div className="checklist-container">
{/* Cabeçalho do Relatório */}
<div className="flex justify-between items-center border-b-2 border-gray-800 pb-4 mb-4">
<div className="flex justify-between items-center border-b-2 border-gray-800 pb-3 mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Checklist de Produção</h1>
<p className="text-gray-600">Formulário para apontamento da fabricação.</p>
<h1 className="text-2xl font-bold text-gray-900 leading-tight">Checklist de Produção</h1>
<p className="text-xs text-gray-600">Formulário para apontamento da fabricação.</p>
</div>
<div className="text-right">
<p className="font-semibold">
<p className="font-semibold text-sm">
Data de Emissão: <span className="font-normal">{dataAtual}</span>
{versaoAtual && (
<span className="ml-2 text-gray-500">Rev. {versaoAtual.revisao}</span>
<span className="ml-2 text-gray-500 font-medium">Rev. {versaoAtual.revisao}</span>
)}
</p>
</div>
</div>
{/* Informações da OF e Fase */}
<div className="border border-gray-200 bg-white p-4 rounded-lg mb-2">
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
<div className="border border-gray-200 bg-white p-3.5 rounded-lg mb-3">
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-3">
{/* Coluna OF */}
<div>
<p className="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
<p className="text-base font-bold text-gray-800">{ofNumber}</p>
<p className="text-base font-bold text-gray-800 leading-snug">{ofNumber}</p>
</div>
{/* Coluna Fase */}
<div>
<p className="text-xs font-medium text-gray-500">Fase</p>
<p className="text-base font-bold text-gray-800">{etapaFase}</p>
<p className="text-base font-bold text-gray-800 leading-snug">{etapaFase}</p>
</div>
{/* Coluna Processo */}
<div className="md:col-span-2">
<p className="text-xs font-medium text-gray-500">PROCESSO</p>
<div className="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1">
<p className="text-xs font-medium text-gray-500 mb-1">PROCESSO</p>
<div style={{ marginTop: '2px' }}>
{['Corte', 'Solda', 'Pintura', 'Expedição'].map((processo) => (
<div key={processo} className="flex items-center gap-1">
<div className="w-4 h-4 border-2 border-gray-500 check-box-print"></div>
<span className="text-sm font-semibold text-gray-700">{processo}</span>
<div
key={processo}
style={{
display: 'inline-block',
verticalAlign: 'middle',
marginRight: '16px',
whiteSpace: 'nowrap'
}}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
style={{ display: 'inline-block', verticalAlign: '-2px', marginRight: '4px' }}
>
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1.5" />
</svg>
<span style={{ fontSize: '13px', fontWeight: 600, color: '#374151', verticalAlign: 'middle' }}>
{processo}
</span>
</div>
))}
</div>
@@ -167,32 +206,39 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
</div>
{/* Legenda */}
<div className="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3">
<span className="font-semibold">Legenda:</span>
<span>Marca (Qtd)</span>
<span className="font-medium text-gray-500">(S/M)</span>
<span>= Sem Montagem,</span>
<span className="font-medium text-gray-500">(C/M)</span>
<span>= Com Montagem. Os quadrados</span>
<div className="tick-box inline-block"></div>
<div className="text-xs text-gray-600 mb-5 flex items-center flex-wrap">
<span className="font-semibold" style={{ marginRight: '6px' }}>Legenda:</span>
<span style={{ marginRight: '6px' }}>Marca (Qtd)</span>
<span className="font-medium text-gray-500" style={{ marginRight: '4px' }}>(S/M)</span>
<span style={{ marginRight: '6px' }}>= Sem Montagem,</span>
<span className="font-medium text-gray-500" style={{ marginRight: '4px' }}>(C/M)</span>
<span style={{ marginRight: '4px' }}>= Com Montagem. Os quadrados</span>
<svg
width="13"
height="13"
viewBox="0 0 13 13"
style={{ display: 'inline-block', verticalAlign: '-2px', margin: '0 4px' }}
>
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1" />
</svg>
<span>indicam o controle de peças fabricadas.</span>
</div>
{/* Itens por Prioridade */}
<div className="space-y-8">
<div className="space-y-6">
{['P1', 'P2', 'P3', 'P4'].map((codigo, priorityIndex) => {
const itens = itensPorPrioridade[codigo] || [];
if (itens.length === 0) return null;
return (
<div key={codigo} className={priorityIndex > 0 ? 'page-break' : ''}>
<h2 className={`text-lg font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-3`}>
<h2 className={`text-base font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-2.5 border`}>
{getPrioridadeNome(codigo)}
</h2>
<div className="space-y-1">
<div className="space-y-1.5">
{Array.from({ length: Math.ceil(itens.length / 3) }, (_, i) => {
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50' : 'bg-white';
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50/70' : 'bg-white';
const rowItems = itens.slice(i * 3, (i + 1) * 3);
return (
@@ -205,15 +251,17 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
return (
<div key={item.id} className="item-card">
<div className="flex items-center flex-wrap gap-2 mb-2">
<span className="font-semibold text-sm whitespace-nowrap">
<div style={{ marginBottom: '6px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<span style={{ fontSize: '13px', fontWeight: 700, color: '#111827', verticalAlign: 'middle', marginRight: '4px' }}>
{marca} ({quantidade})
</span>
<span className="text-xs font-medium text-gray-500">{infoType}</span>
<span style={{ fontSize: '11px', fontWeight: 600, color: '#6b7280', verticalAlign: 'middle' }}>
{infoType}
</span>
{generateTickBoxes(quantidade)}
</div>
<div className="mt-2 text-xs">
<div className="border-b border-gray-400 pb-1 h-5">Data/Operador:</div>
<div className="item-signature">
Data/Operador:
</div>
</div>
);
@@ -232,5 +280,6 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
})}
</div>
</div>
</div>
);
};
@@ -78,22 +78,26 @@ export const ApontamentoDiarioChart = () => {
return { valorMaximo, linhasGrid: linhas };
}, [dadosProcessados]);
// Cores para cada OF
const coresOF = {
'B114': 'bg-blue-500',
'B117': 'bg-amber-500',
'B118': 'bg-emerald-500',
'B119': 'bg-purple-500',
'B120': 'bg-pink-500',
'B121': 'bg-indigo-500',
'B122': 'bg-cyan-500',
'B123': 'bg-teal-500',
'B124': 'bg-lime-500',
'B125': 'bg-red-500',
};
// Array de cores profissionais e vibrantes para as OFs
const availableColors = [
'bg-blue-500',
'bg-emerald-500',
'bg-violet-500',
'bg-amber-500',
'bg-rose-500',
'bg-cyan-500',
'bg-fuchsia-500',
'bg-lime-500',
'bg-indigo-500',
'bg-orange-500',
'bg-teal-500',
'bg-pink-500'
];
const getCorOF = (ofNumber: string) => {
return coresOF[ofNumber as keyof typeof coresOF] || 'bg-gray-500';
const index = ofsUnicas.indexOf(ofNumber);
if (index === -1) return 'bg-gray-500';
return availableColors[index % availableColors.length];
};
const buttons = [
@@ -222,19 +226,19 @@ export const ApontamentoDiarioChart = () => {
</CardHeader>
<CardContent>
{/* Legenda interativa */}
<div className="flex justify-center flex-wrap gap-4 mb-8">
<div className="flex justify-center flex-wrap gap-3 mb-8">
{ofsUnicas.map((ofNumber) => (
<button
key={ofNumber}
onClick={() => setSelectedOF(selectedOF === ofNumber ? null : ofNumber)}
className={`flex items-center gap-2 px-3 py-1 rounded-md transition-all ${
className={`flex items-center gap-2 px-4 py-1.5 rounded-full border shadow-sm transition-all duration-200 ${
selectedOF === ofNumber
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
? 'bg-primary text-primary-foreground border-primary scale-105 shadow-md'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground'
}`}
>
<div className={`w-4 h-4 rounded-sm ${getCorOF(ofNumber)}`} />
<span className="text-sm font-medium">OF {ofNumber}</span>
<div className={`w-3 h-3 rounded-full shadow-inner ${getCorOF(ofNumber)}`} />
<span className="text-sm font-semibold">OF {ofNumber}</span>
</button>
))}
{selectedOF && (
@@ -262,7 +266,7 @@ export const ApontamentoDiarioChart = () => {
style={{ bottom: `${linha.porcentagem}%`, transform: 'translateY(50%)' }}
>
<span className="text-xs text-muted-foreground mr-2">
{linha.valor >= 1000 ? `${(linha.valor/1000).toFixed(0)}t` : `${linha.valor}kg`}
{Math.round(linha.valor).toLocaleString('pt-BR')} Kg
</span>
<div className="w-2 h-px bg-border"></div>
</div>
@@ -315,7 +319,7 @@ export const ApontamentoDiarioChart = () => {
>
{alturaPixels > 20 && (
<span className="transform rotate-90 whitespace-nowrap">
{ofData.peso > 999 ? `${Math.round(ofData.peso/1000)}t` : `${ofData.peso}kg`}
{Math.round(ofData.peso).toLocaleString('pt-BR')}
</span>
)}
</div>
@@ -47,14 +47,8 @@ export const RelatorioPecasProcessoModal: React.FC<RelatorioPecasProcessoModalPr
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto bg-card border-border">
<DialogHeader>
<DialogTitle className="flex items-center justify-between text-card-foreground">
<span>Relatório de Peças por Processo</span>
<button
onClick={onClose}
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<X className="h-4 w-4" />
</button>
<DialogTitle className="text-card-foreground">
Relatório de Peças por Processo
</DialogTitle>
</DialogHeader>
@@ -33,37 +33,50 @@ export const RelatorioPecasProcessoPDF: React.FC<RelatorioPecasProcessoPDFProps>
}
try {
const doc = new jsPDF('l', 'mm', 'a4'); // Landscape orientation
const doc = new jsPDF('p', 'mm', 'a4'); // Portrait orientation para otimizar espaço
const faseText = selectedFase === 'todas' ? '' : ` - Fase ${selectedFase}`;
// Título
doc.setFontSize(20);
doc.setFont('helvetica', 'bold');
doc.text('Relatório de Status', 20, 20);
// Subtítulo
doc.setFontSize(14);
doc.setFont('helvetica', 'normal');
doc.text(`Ordem de Fabricação: ${selectedOF}${faseText}`, 20, 30);
// Data/hora
doc.setFontSize(10);
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 20, 40);
// Resumo por Processo
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.text('Resumo por Processo', 20, 55);
doc.text('Relatório de Status de Produção', 14, 16);
// Subtítulo e Data
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
let yPos = 65;
doc.text(`Total de Peças: ${estatisticas.totalPecas}`, 20, yPos);
doc.text(`Peso Corte: ${estatisticas.pesoTotalCorte.toFixed(2)} kg`, 80, yPos);
doc.text(`Peso Solda: ${estatisticas.pesoTotalSolda.toFixed(2)} kg`, 140, yPos);
doc.text(`Peso Pintura: ${estatisticas.pesoTotalPintura.toFixed(2)} kg`, 200, yPos);
doc.text(`Peso Expedição: ${estatisticas.pesoTotalExpedicao.toFixed(2)} kg`, 260, yPos);
doc.text(`OF: ${selectedOF}${faseText} | Gerado em: ${new Date().toLocaleString('pt-BR')}`, 14, 23);
// Resumo por Processo em linha mais compacta
let yPos = 32;
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.text(`Total Peças:`, 14, yPos);
doc.setFont('helvetica', 'normal');
doc.text(`${estatisticas.totalPecas}`, 36, yPos);
doc.setFont('helvetica', 'bold');
doc.text(`Corte:`, 50, yPos);
doc.setFont('helvetica', 'normal');
doc.text(`${estatisticas.pesoTotalCorte.toFixed(0)} kg`, 62, yPos);
doc.setFont('helvetica', 'bold');
doc.text(`Solda:`, 90, yPos);
doc.setFont('helvetica', 'normal');
doc.text(`${estatisticas.pesoTotalSolda.toFixed(0)} kg`, 102, yPos);
doc.setFont('helvetica', 'bold');
doc.text(`Pintura:`, 130, yPos);
doc.setFont('helvetica', 'normal');
doc.text(`${estatisticas.pesoTotalPintura.toFixed(0)} kg`, 145, yPos);
doc.setFont('helvetica', 'bold');
doc.text(`Expedição:`, 175, yPos);
doc.setFont('helvetica', 'normal');
doc.text(`${estatisticas.pesoTotalExpedicao.toFixed(0)} kg`, 192, yPos);
yPos = 38; // Ajusta yPos para o início da tabela
// Preparar dados para a tabela
const tableData = pecasComStatus.map(peca => [
@@ -88,36 +101,35 @@ export const RelatorioPecasProcessoPDF: React.FC<RelatorioPecasProcessoPDFProps>
autoTable(doc, {
head: headers,
body: tableData,
startY: yPos + 15,
startY: yPos,
styles: {
fontSize: 9,
cellPadding: 2,
lineColor: [204, 204, 204],
lineWidth: 0.5,
fontSize: 8,
cellPadding: 1.5,
lineColor: [210, 210, 210],
lineWidth: 0.1,
},
headStyles: {
fillColor: [233, 236, 239],
textColor: 0,
fillColor: [240, 240, 240],
textColor: [40, 40, 40],
fontStyle: 'bold',
lineColor: [204, 204, 204],
lineWidth: 0.5,
lineWidth: 0.1,
},
alternateRowStyles: {
fillColor: [248, 249, 250],
fillColor: [250, 250, 250],
},
columnStyles: {
0: { cellWidth: 25, halign: 'center' }, // OF
1: { cellWidth: 20, halign: 'center' }, // Fase
2: { cellWidth: 25, halign: 'center' }, // Marca
3: { cellWidth: 15, halign: 'center' }, // Qtd
4: { cellWidth: 25, halign: 'right' }, // Peso Unit
5: { cellWidth: 25, halign: 'right' }, // Peso Total
6: { cellWidth: 20, halign: 'center' }, // Corte
7: { cellWidth: 20, halign: 'center' }, // Solda
8: { cellWidth: 25, halign: 'center' }, // Pint/Galv
9: { cellWidth: 25, halign: 'center' }, // Expedição
0: { cellWidth: 18, halign: 'center' }, // OF
1: { cellWidth: 12, halign: 'center' }, // Fase
2: { cellWidth: 20, halign: 'center' }, // Marca
3: { cellWidth: 10, halign: 'center' }, // Qtd
4: { cellWidth: 22, halign: 'right' }, // Peso Unit
5: { cellWidth: 22, halign: 'right' }, // Peso Total
6: { cellWidth: 16, halign: 'center' }, // Corte
7: { cellWidth: 16, halign: 'center' }, // Solda
8: { cellWidth: 22, halign: 'center' }, // Pint/Galv
9: { cellWidth: 22, halign: 'center' }, // Expedição
},
margin: { left: 20, right: 20 },
margin: { left: 14, right: 14, top: 20, bottom: 20 },
tableWidth: 'auto',
});
@@ -41,15 +41,18 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
printTemplate.innerHTML = `
<style>
@media print {
@page { size: A4 portrait; margin: 1cm; }
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: white;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.a4-page {
width: 100%;
margin: 0;
padding: 1.5cm;
padding: 0;
background: white;
}
table {
@@ -58,18 +61,19 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
page-break-inside: auto;
}
th, td {
border: 1px solid #ccc;
padding: 4px 8px;
font-size: 9pt;
border: 1px solid #e5e7eb;
padding: 3px 6px;
font-size: 8pt;
text-align: center;
}
th {
background-color: #e9ecef;
background-color: #f3f4f6;
color: #374151;
font-weight: 600;
}
.text-left { text-align: left; }
.text-right { text-align: right; }
.header-info, .summary-info { font-size: 10pt; }
.header-info, .summary-info { font-size: 9pt; }
tr { page-break-inside: avoid; }
thead { display: table-header-group; }
}
@@ -81,7 +85,7 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
.a4-page {
width: 21cm;
min-height: 29.7cm;
padding: 1.5cm;
padding: 1cm;
margin: 1cm auto;
background: white;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
@@ -91,44 +95,43 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
border-collapse: collapse;
}
th, td {
border: 1px solid #ccc;
padding: 4px 8px;
font-size: 9pt;
border: 1px solid #e5e7eb;
padding: 3px 6px;
font-size: 8pt;
text-align: center;
}
th {
background-color: #e9ecef;
background-color: #f3f4f6;
color: #374151;
font-weight: 600;
}
.text-left { text-align: left; }
.text-right { text-align: right; }
.header-info, .summary-info { font-size: 10pt; }
.header-info, .summary-info { font-size: 9pt; }
</style>
<div class="a4-page">
<!-- Cabeçalho -->
<header style="margin-bottom: 16px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid #ccc; padding-bottom: 8px;">
<header style="margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 1px solid #ccc; padding-bottom: 4px;">
<div>
<h1 style="font-size: 20px; font-weight: bold; color: #1f2937; margin: 0 0 4px 0;">Relatório de Status</h1>
<p style="font-size: 14px; color: #6b7280; margin: 0;" class="header-info">Ordem de Fabricação: <span style="font-weight: 600;">${selectedOF}${faseText}</span></p>
<h1 style="font-size: 18px; font-weight: bold; color: #1f2937; margin: 0 0 2px 0;">Relatório de Status de Produção</h1>
<p style="font-size: 12px; color: #6b7280; margin: 0;" class="header-info">OF: <span style="font-weight: 600;">${selectedOF}${faseText}</span></p>
</div>
<div style="text-align: right;">
<p style="font-size: 14px; color: #6b7280; margin: 0;" class="header-info">Gerado em:</p>
<p style="font-size: 14px; color: #9ca3af; margin: 0;" class="header-info">${currentDate}</p>
<p style="font-size: 11px; color: #9ca3af; margin: 0;" class="header-info">Gerado em: ${currentDate}</p>
</div>
</div>
</header>
<!-- Resumo -->
<section style="margin-bottom: 16px;">
<h2 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #374151;">Resumo por Processo</h2>
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; font-size: 12px; color: #6b7280; padding: 8px; border: 1px solid #e5e7eb; border-radius: 8px; background-color: #f9fafb;" class="summary-info">
<div><strong>Total de Peças:</strong> <span style="font-family: monospace;">${estatisticas.totalPecas}</span></div>
<div><strong>Peso Corte:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalCorte.toFixed(2)} kg</span></div>
<div><strong>Peso Solda:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalSolda.toFixed(2)} kg</span></div>
<div><strong>Peso Pintura:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalPintura.toFixed(2)} kg</span></div>
<div><strong>Peso Expedição:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalExpedicao.toFixed(2)} kg</span></div>
<section style="margin-bottom: 12px;">
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; font-size: 11px; color: #4b5563; padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 6px; background-color: #f9fafb;" class="summary-info">
<div><strong>Total Peças:</strong> ${estatisticas.totalPecas}</div>
<div><strong>Corte:</strong> ${estatisticas.pesoTotalCorte.toFixed(0)} kg</div>
<div><strong>Solda:</strong> ${estatisticas.pesoTotalSolda.toFixed(0)} kg</div>
<div><strong>Pintura:</strong> ${estatisticas.pesoTotalPintura.toFixed(0)} kg</div>
<div><strong>Expedição:</strong> ${estatisticas.pesoTotalExpedicao.toFixed(0)} kg</div>
</div>
</section>
+7 -14
View File
@@ -17,8 +17,7 @@ import {
Settings,
Shield,
UserCog,
Cog,
Globe
Cog
} from "lucide-react";
import { MenuItem, MenuGroup } from './types';
@@ -40,9 +39,9 @@ export const menuGroups: MenuGroup[] = [
icon: FileText,
subItems: [
{
key: "ficha-tecnica-of",
title: "Ficha Técnica da OF",
url: "/cadastro-of",
key: "ordens-fabricacao",
title: "Ordens de Fabricação",
url: "/ofs",
},
{
key: "cadastro-pecas",
@@ -71,12 +70,6 @@ export const menuGroups: MenuGroup[] = [
key: "inconsistencias",
title: "Ver Inconsistências",
url: "/ferramentas/inconsistencias",
},
{
key: "webclone",
title: "WebClone AI",
url: "/ferramentas/webclone",
icon: Globe,
}
]
},
@@ -103,9 +96,9 @@ export const menuGroups: MenuGroup[] = [
icon: FolderOpen,
subItems: [
{
key: "ordens-fabricacao",
title: "Ordens de Fabricação",
url: "/ofs",
key: "ficha-tecnica-of",
title: "Ficha Técnica da OF",
url: "/cadastro-of",
},
{
key: "cronograma",
+29 -2
View File
@@ -20,7 +20,8 @@ import { UserProfile, UserFunction, UserPrivilege } from '@/hooks/useUserManagem
import { AvatarUpload } from '@/components/ui/avatar-upload';
import { useProfileImage } from '@/hooks/useProfileImage';
import { usePasswordManagement } from '@/hooks/usePasswordManagement';
import { Eye, EyeOff } from 'lucide-react';
import { useUserManagement } from '@/hooks/useUserManagement';
import { Eye, EyeOff, AlertTriangle } from 'lucide-react';
interface UserModalProps {
user: UserProfile | null;
@@ -35,7 +36,9 @@ interface UserModalProps {
export function UserModal({ user, functions, privileges, onSave, onCreate, onClose, readOnly = false }: UserModalProps) {
const { updateProfileImage, removeProfileImage, updating } = useProfileImage();
const { changeUserPassword, isChangingPassword } = usePasswordManagement();
const { deleteUserAndLogtoAccount } = useUserManagement();
const [showPassword, setShowPassword] = useState(false);
const [isDeletingLogto, setIsDeletingLogto] = useState(false);
const [formData, setFormData] = useState({
email: '',
full_name: '',
@@ -126,6 +129,18 @@ export function UserModal({ user, functions, privileges, onSave, onCreate, onClo
onClose();
};
const handleDeleteLogtoAndSupabase = async () => {
if (!user || isReadOnly) return;
if (confirm(`ATENÇÃO! Esta ação irá apagar a conta do usuário ${user.email} permanentemente no LOGTO e no Supabase. O usuário perderá o acesso totalmente. Tem certeza?`)) {
setIsDeletingLogto(true);
const success = await deleteUserAndLogtoAccount(user.id, user.email);
setIsDeletingLogto(false);
if (success) {
onClose();
}
}
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-md">
@@ -297,11 +312,23 @@ export function UserModal({ user, functions, privileges, onSave, onCreate, onClo
<Button
type="submit"
className="bg-blue-600 hover:bg-blue-700"
disabled={updating || (isCreateMode && !formData.email)}
disabled={updating || (isCreateMode && !formData.email) || isDeletingLogto}
>
{isCreateMode ? 'Criar Usuário' : 'Salvar'}
</Button>
)}
{!isCreateMode && !isReadOnly && (
<Button
type="button"
variant="destructive"
onClick={handleDeleteLogtoAndSupabase}
disabled={isDeletingLogto}
className="flex items-center gap-1 bg-red-600 hover:bg-red-700"
>
<AlertTriangle className="h-4 w-4" />
{isDeletingLogto ? 'Excluindo...' : 'Excluir Conta Logto'}
</Button>
)}
<Button type="button" variant="ghost" onClick={onClose}>
{isReadOnly ? 'Fechar' : 'Cancelar'}
</Button>
+9 -9
View File
@@ -8,7 +8,7 @@ export type RouteGuard = 'public' | 'protected' | 'admin' | 'resource';
export interface RouteDefinition {
path: string;
component: () => Promise<{ default: ComponentType<any> }>;
component: () => Promise<{ default: ComponentType<unknown> }>;
guard: RouteGuard;
resourceKey?: string;
}
@@ -24,6 +24,11 @@ export const routeConfig: RouteDefinition[] = [
component: () => import('@/pages/Auth'),
guard: 'public',
},
{
path: '/callback',
component: () => import('@/pages/Callback'),
guard: 'public',
},
// ── Dashboard ────────────────────────────────────────────
{
@@ -78,12 +83,7 @@ export const routeConfig: RouteDefinition[] = [
guard: 'resource',
resourceKey: 'ferramentas-inconsistencias',
},
{
path: '/ferramentas/webclone',
component: () => import('@/pages/WebClone'),
guard: 'resource',
resourceKey: 'ferramentas-webclone',
},
// ── Estoque ──────────────────────────────────────────────
{
@@ -229,13 +229,13 @@ export const routeConfig: RouteDefinition[] = [
{
path: '/configuracoes',
component: () => import('@/pages/Configuracoes'),
guard: 'resource',
guard: 'admin',
resourceKey: 'configuracoes-gerais',
},
{
path: '/admin/theme-customization',
component: () => import('@/pages/ThemeCustomizationPage'),
guard: 'resource',
guard: 'admin',
resourceKey: 'theme-customization',
},
+4 -3
View File
@@ -86,9 +86,9 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
data_apontamento,
quantidade_produzida,
tipo_apontamento,
processo:processos_fabricacao(nome, ordem),
peca:pecas(peso_unitario),
componente:componentes_peca(peso_unitario)
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario)
`)
.gte('data_apontamento', dataInicio)
.lte('data_apontamento', dataFim)
@@ -216,6 +216,7 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [periodo]);
return {
+26 -18
View File
@@ -84,16 +84,8 @@ export const useApontamentosProducao = () => {
console.log('🔍 Iniciando busca COMPLETA de apontamentos...');
// Verificar se o usuário está autenticado
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError) {
console.error('❌ Erro de autenticação:', authError);
toast.error('Erro de autenticação: ' + authError.message);
return;
}
if (!user) {
console.warn('⚠️ Usuário não autenticado');
toast.error('Usuário não autenticado');
console.warn('⚠️ Usuário não autenticado em useApontamentosProducao');
return;
}
@@ -121,7 +113,7 @@ export const useApontamentosProducao = () => {
console.log(`📊 TOTAL DE REGISTROS NA TABELA: ${totalCount}`);
// SEGUNDA VERIFICAÇÃO: Buscar TODOS os apontamentos sem qualquer limitação
let allApontamentos: any[] = [];
let allApontamentos: ApontamentoProducao[] = [];
let pageNumber: number = 0;
const itemsPerPage: number = 1000; // Buscar em lotes de 1000 para evitar timeout
let hasMoreData: boolean = true;
@@ -137,9 +129,9 @@ export const useApontamentosProducao = () => {
.from('apontamentos_producao')
.select(`
*,
peca:pecas(marca, descricao, peso_unitario, etapa_fase),
processo:processos_fabricacao(nome, ordem),
componente:componentes_peca(marca_componente, descricao, peso_unitario)
peca:pecas!apontamentos_producao_peca_id_fkey(marca, descricao, peso_unitario, etapa_fase),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente, descricao, peso_unitario)
`)
.range(startIndex, endIndex)
.order('created_at', { ascending: false });
@@ -156,7 +148,7 @@ export const useApontamentosProducao = () => {
break;
}
allApontamentos = [...allApontamentos, ...pageData];
allApontamentos = [...allApontamentos, ...(pageData as unknown as ApontamentoProducao[])];
console.log(`✅ Página ${pageNumber + 1}: ${pageData.length} registros carregados. Total acumulado: ${allApontamentos.length}`);
// Se retornou menos que o itemsPerPage, chegamos ao fim
@@ -220,7 +212,7 @@ export const useApontamentosProducao = () => {
toast.error('Erro desconhecido ao carregar apontamentos');
}
}
}, []);
}, [user]);
const fetchProcessos = useCallback(async () => {
try {
@@ -325,7 +317,19 @@ export const useApontamentosProducao = () => {
console.log('🔄 Criando apontamento:', apontamento);
const insertData: any = {
interface InsertApontamentoData {
of_number: string;
tipo_apontamento: 'peca' | 'componente';
processo_id: string;
quantidade_produzida: number;
data_apontamento: string;
observacoes?: string | null;
created_by?: string;
peca_id?: string | null;
componente_id?: string | null;
}
const insertData: InsertApontamentoData = {
of_number: apontamento.of_number,
tipo_apontamento: apontamento.tipo_apontamento,
processo_id: apontamento.processo_id,
@@ -377,9 +381,13 @@ export const useApontamentosProducao = () => {
toast.success('Apontamento registrado com sucesso!');
return { success: true };
} catch (error) {
} catch (error: any) {
console.error('❌ Erro ao criar apontamento:', error);
toast.error('Erro ao registrar apontamento: ' + (error as any).message);
const isConflict = error?.status === 409 || error?.code === '23505' || String(error?.message || '').includes('409');
const errorMessage = isConflict
? 'Este item ou processo já teve o apontamento registrado por outro usuário ou os dados em cache estão desatualizados. Por favor, clique em "Limpar" no cache e atualize a página.'
: (error instanceof Error ? error.message : String(error));
toast.error('Erro ao registrar apontamento: ' + errorMessage);
return { success: false, error };
}
}, [user, fetchApontamentos]);
@@ -203,9 +203,9 @@ export const useApontamentosProducaoOtimizado = () => {
.from('apontamentos_producao')
.select(`
*,
peca:pecas(marca, descricao),
processo:processos_fabricacao(nome, ordem),
componente:componentes_peca(marca_componente, descricao)
peca:pecas!apontamentos_producao_peca_id_fkey(marca, descricao),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente, descricao)
`)
.eq('of_number', ofNumber)
.order('created_at', { ascending: false });
+9 -8
View File
@@ -3,15 +3,15 @@ import { useState, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { naturalSort } from '@/utils/naturalSort';
interface InconsistenciaItem {
export interface InconsistenciaItem {
marca: string;
tipo: string;
descricao: string;
detalhes: any;
detalhes: Record<string, unknown>;
acaoSugerida?: string;
}
interface ResultadoAuditoria {
export interface ResultadoAuditoria {
inconsistencias: {
processos: InconsistenciaItem[];
quantidades: InconsistenciaItem[];
@@ -50,9 +50,9 @@ export const useAuditoriaInconsistencias = () => {
.from('apontamentos_producao')
.select(`
*,
processo:processos_fabricacao(nome, ordem),
peca:pecas(marca, etapa_fase, quantidade),
componente:componentes_peca(marca_componente)
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase, quantidade),
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente)
`)
.eq('of_number', ofNumber)
.then(({ data }) => data || []),
@@ -253,8 +253,9 @@ export const useAuditoriaInconsistencias = () => {
});
// Aplicar ordenação natural
Object.keys(inconsistencias).forEach(categoria => {
(inconsistencias as any)[categoria].sort((a: any, b: any) => naturalSort(a.marca, b.marca));
Object.keys(inconsistencias).forEach(key => {
const categoria = key as keyof typeof inconsistencias;
inconsistencias[categoria].sort((a: InconsistenciaItem, b: InconsistenciaItem) => naturalSort(a.marca, b.marca));
});
const resultado: ResultadoAuditoria = {
+147 -363
View File
@@ -1,401 +1,185 @@
import { useState, useEffect, createContext, useContext, ReactNode } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '@/integrations/supabase/client';
import { logger } from '@/utils/logger';
// Hook de autenticação usando Logto (sem Supabase Auth)
// Mantém a MESMA shape do useAuth original pra não quebrar consumers
interface AuthContextType {
user: User | null;
session: Session | null;
loading: boolean;
signIn: (email: string, password: string) => Promise<{ error: any }>;
signUp: (email: string, password: string) => Promise<{ error: any }>;
signOut: () => Promise<void>;
updatePassword: (password: string) => Promise<{ error: any }>;
isRecoveryFlow: boolean;
import React, {
createContext,
useContext,
useEffect,
useState,
useCallback,
useRef,
ReactNode,
} from 'react';
import { supabase } from '@/integrations/supabase/client';
import {
LogtoUser,
signIn as logtoSignIn,
signOut as logtoSignOut,
getUser as logtoGetUser,
handleCallback,
isAuthenticated,
requestPasswordReset,
} from '@/lib/logto/client';
// Sincroniza user Logto com profiles Supabase e retorna o UUID real do Supabase
async function syncUserToProfile(user: LogtoUser): Promise<string> {
if (!user?.sub) return user.sub;
const email = user.email || `${user.username || 'user'}@logto.local`;
try {
// 1. Tenta buscar o perfil existente pelo email
const { data: existing } = await supabase
.from('profiles')
.select('id')
.eq('email', email)
.maybeSingle();
let profileId = existing?.id;
// 2. Se existe, atualizamos o nome e retornamos o UUID
if (profileId) {
const name = user.name || user.username;
if (name) {
await supabase.from('profiles').update({
full_name: name,
}).eq('id', profileId);
}
return profileId;
}
// 3. Se não existir, criamos um novo com um UUID gerado
profileId = crypto.randomUUID();
const { error: insertError } = await supabase.from('profiles').insert({
id: profileId,
email: email,
full_name: user.name || user.username || 'Usuário Logto',
});
if (insertError) {
console.warn('Erro ao inserir novo profile no Supabase:', insertError);
return user.sub;
}
return profileId;
} catch (err) {
console.warn('Erro ao sincronizar profile Supabase:', err);
return user.sub;
}
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export interface UseAuthReturn {
user: LogtoUser | null;
loading: boolean;
authInitialized: boolean;
isRecoveryFlow: boolean;
signIn: (email?: string, password?: string) => Promise<{ error: unknown }>;
signUp: (email: string, password: string) => Promise<{ error: unknown }>;
signOut: () => Promise<void>;
updatePassword: (password: string) => Promise<{ error: unknown }>;
handleCallback: () => Promise<boolean>;
}
const AuthContext = createContext<UseAuthReturn | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<LogtoUser | null>(null);
const [loading, setLoading] = useState(true);
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
const [authInitialized, setAuthInitialized] = useState(false);
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
const callbackHandled = useRef(false);
// Verifica se o usuário é admin ou desenvolvedor (excluídos dos logs de sessão)
const isAdminOrDeveloper = async (userId: string): Promise<boolean> => {
try {
const { data: adminRoles, error: adminError } = await supabase
.from('user_roles')
.select('role')
.eq('user_id', userId)
.eq('role', 'admin')
.maybeSingle();
// Detecta callback URL e processa
useEffect(() => {
const url = new URL(window.location.href);
if (url.searchParams.has('code')) {
if (callbackHandled.current) return;
callbackHandled.current = true;
if (!adminError && adminRoles) {
return true;
setLoading(true);
handleCallback().then((ok) => {
if (ok) {
logtoGetUser().then(async (u) => {
if (u) {
const profileId = await syncUserToProfile(u);
u.id = profileId;
}
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('function_id, functions(name)')
.eq('id', userId)
.maybeSingle();
if (profileError) {
return false;
}
if (profile?.functions?.name === 'Desenvolvedor') {
return true;
}
return false;
} catch {
return false;
}
};
// Processa tokens de recuperação de senha na URL
const processRecoveryTokens = () => {
try {
const currentUrl = window.location.href;
const urlObj = new URL(currentUrl);
const recoveryType = urlObj.searchParams.get('type');
const hashParams = new URLSearchParams(urlObj.hash.substring(1));
const accessToken = hashParams.get('access_token');
const refreshToken = hashParams.get('refresh_token');
const tokenType = hashParams.get('type');
const isRecovery = recoveryType === 'recovery' || tokenType === 'recovery' || (accessToken && refreshToken);
if (isRecovery) {
setIsRecoveryFlow(true);
if (accessToken && refreshToken) {
supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken
}).then(({ data, error }) => {
if (!error) {
setSession(data.session);
setUser(data.session?.user || null);
setIsRecoveryFlow(true);
}
const cleanUrl = `${window.location.origin}/auth?type=recovery`;
window.history.replaceState({}, '', cleanUrl);
setUser(u);
});
}
return true;
}
return false;
} catch {
return false;
}
};
// Marca usuário como online no sistema
const setUserOnline = async (userId: string) => {
if (!authInitialized) return;
try {
const shouldSkipLogging = await isAdminOrDeveloper(userId);
if (shouldSkipLogging) return;
try {
await supabase.rpc('set_user_online', { user_id_param: userId });
} catch {
// RPC não encontrada — continua sem erro
}
} catch (error) {
logger.error('Erro ao marcar usuário como online:', error);
}
};
const setUserOffline = async (userId: string) => {
if (!authInitialized) return;
try {
const shouldSkipLogging = await isAdminOrDeveloper(userId);
if (shouldSkipLogging) return;
try {
await supabase.rpc('set_user_offline', { user_id_param: userId });
} catch {
// RPC não encontrada — continua sem erro
}
} catch (error) {
logger.error('Erro ao marcar usuário como offline:', error);
}
};
const startSessionLog = async (userId: string) => {
if (!authInitialized) return;
try {
const shouldSkipLogging = await isAdminOrDeveloper(userId);
if (shouldSkipLogging) return;
const { data, error } = await supabase
.from('user_session_logs')
.insert({
user_id: userId,
user_agent: navigator.userAgent,
is_active: true
})
.select()
.single();
if (error) {
logger.error('Erro ao registrar início de sessão:', error);
} else if (data) {
localStorage.setItem('currentSessionId', data.id);
}
} catch (error) {
logger.error('Erro inesperado ao registrar início de sessão:', error);
}
};
const endSessionLog = async () => {
if (!authInitialized) return;
try {
const sessionId = localStorage.getItem('currentSessionId');
if (sessionId) {
try {
await supabase.rpc('end_user_session', { session_id: sessionId });
} catch {
// RPC não encontrada — continua sem erro
}
localStorage.removeItem('currentSessionId');
}
} catch (error) {
logger.error('Erro ao finalizar sessão:', error);
}
};
useEffect(() => {
let mounted = true;
const initializeAuth = async () => {
try {
const hasRecoveryTokens = processRecoveryTokens();
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (!mounted) return;
setSession(session);
setUser(session?.user ?? null);
if (event === 'SIGNED_IN' && session?.user) {
setTimeout(() => {
if (mounted && authInitialized) {
setUserOnline(session.user.id);
startSessionLog(session.user.id);
}
}, 100);
} else if (event === 'SIGNED_OUT') {
setIsRecoveryFlow(false);
const currentUser = user;
if (currentUser && mounted && authInitialized) {
setTimeout(() => {
setUserOffline(currentUser.id);
endSessionLog();
}, 100);
}
}
}
);
if (!hasRecoveryTokens) {
try {
const { data: { session } } = await supabase.auth.getSession();
if (mounted && session?.user) {
setSession(session);
setUser(session.user);
setTimeout(() => {
if (mounted) {
setAuthInitialized(true);
setUserOnline(session.user.id);
const existingSessionId = localStorage.getItem('currentSessionId');
if (!existingSessionId) {
startSessionLog(session.user.id);
}
}
}, 100);
} else if (mounted) {
setSession(null);
setUser(null);
setAuthInitialized(true);
}
} catch (sessionError) {
logger.error('Erro ao verificar sessão:', sessionError);
if (mounted) {
setSession(null);
setUser(null);
setAuthInitialized(true);
}
}
setLoading(false);
});
} else {
setAuthInitialized(true);
if (isAuthenticated()) {
logtoGetUser().then(async (u) => {
if (u) {
const profileId = await syncUserToProfile(u);
u.id = profileId;
}
if (mounted) {
setUser(u);
setLoading(false);
}
return () => {
mounted = false;
subscription.unsubscribe();
};
} catch (error) {
logger.error('Erro na inicialização da autenticação:', error);
if (mounted) {
setLoading(false);
setSession(null);
setUser(null);
setAuthInitialized(true);
}
}
};
const cleanup = initializeAuth();
return () => {
cleanup.then((cleanupFn) => {
if (cleanupFn) cleanupFn();
});
} else {
setLoading(false);
}
}
}, []);
const signIn = useCallback(async (_email?: string, _password?: string) => {
try {
await logtoSignIn();
return { error: null };
} catch (err) {
return { error: err };
}
}, []);
const signUp = useCallback(async (_email: string, _password: string) => {
// Logto tem tela própria de signup (botão na página de login)
await logtoSignIn();
return { error: null };
}, []);
const signOut = useCallback(async () => {
localStorage.removeItem('userLoginTime');
setIsRecoveryFlow(false);
await logtoSignOut();
}, []);
const updatePassword = useCallback(async (_password: string) => {
return {
error: new Error(
'Para alterar senha, acesse as configurações da conta no Logto (gerenciado pelo IdP)'
),
};
}, []);
// Limpeza periódica de usuários offline e listener de fechamento de janela
useEffect(() => {
let cleanupInterval: NodeJS.Timeout;
const handleCallbackFn = useCallback(async (): Promise<boolean> => {
return await handleCallback();
}, []);
if (user && authInitialized) {
cleanupInterval = setInterval(async () => {
try {
await supabase.rpc('cleanup_offline_users');
} catch {
// Ignora se RPC não existir
}
}, 60000);
const handleBeforeUnload = () => {
if (user) {
const sessionId = localStorage.getItem('currentSessionId');
if (sessionId) {
try {
supabase.rpc('end_user_session', { session_id: sessionId });
} catch {
// Ignora no unload
}
localStorage.removeItem('currentSessionId');
}
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
if (cleanupInterval) {
clearInterval(cleanupInterval);
}
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}
}, [user?.id, authInitialized]);
const signIn = async (email: string, password: string) => {
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
return { error };
} catch (error) {
logger.error('Erro crítico no login:', error);
return { error };
}
};
const signUp = async (email: string, password: string) => {
try {
const redirectUrl = `${window.location.origin}/`;
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: redirectUrl
}
});
return { error };
} catch (error) {
logger.error('Erro crítico no signup:', error);
return { error };
}
};
const signOut = async () => {
const currentUser = user;
if (currentUser && authInitialized) {
setTimeout(() => {
setUserOffline(currentUser.id);
endSessionLog();
}, 100);
}
setIsRecoveryFlow(false);
try {
await supabase.auth.signOut();
} catch (error) {
logger.error('Erro no signOut:', error);
}
};
const updatePassword = async (password: string) => {
try {
const { error } = await supabase.auth.updateUser({
password: password
});
if (!error) {
setIsRecoveryFlow(false);
}
return { error };
} catch (error) {
logger.error('Erro crítico ao atualizar senha:', error);
return { error };
}
};
const value = {
const value: UseAuthReturn = {
user,
session,
loading,
authInitialized,
isRecoveryFlow,
signIn,
signUp,
signOut,
updatePassword,
isRecoveryFlow,
handleCallback: handleCallbackFn,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
export function useAuth(): UseAuthReturn {
const context = useContext(AuthContext);
if (context === undefined) {
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
export { requestPasswordReset };
+428
View File
@@ -0,0 +1,428 @@
import { useState, useEffect, createContext, useContext, ReactNode } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '@/integrations/supabase/client';
import { logger } from '@/utils/logger';
interface AuthContextType {
user: User | null;
session: Session | null;
loading: boolean;
signIn: () => Promise<{ error: unknown }>;
signUp: (email: string, password: string) => Promise<{ error: unknown }>;
signOut: () => Promise<void>;
updatePassword: (password: string) => Promise<{ error: unknown }>;
isRecoveryFlow: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
const [authInitialized, setAuthInitialized] = useState(false);
// Verifica se o usuário é admin ou desenvolvedor (excluídos dos logs de sessão)
const isAdminOrDeveloper = async (userId: string): Promise<boolean> => {
try {
const { data: adminRoles, error: adminError } = await supabase
.from('user_roles')
.select('role')
.eq('user_id', userId)
.eq('role', 'admin')
.maybeSingle();
if (!adminError && adminRoles) {
return true;
}
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('function_id, functions(name)')
.eq('id', userId)
.maybeSingle();
if (profileError) {
return false;
}
if (profile?.functions?.name === 'Desenvolvedor') {
return true;
}
return false;
} catch {
return false;
}
};
// Processa tokens de recuperação de senha na URL
const processRecoveryTokens = () => {
try {
const currentUrl = window.location.href;
const urlObj = new URL(currentUrl);
const recoveryType = urlObj.searchParams.get('type');
const hashParams = new URLSearchParams(urlObj.hash.substring(1));
const accessToken = hashParams.get('access_token');
const refreshToken = hashParams.get('refresh_token');
const tokenType = hashParams.get('type');
const isRecovery = recoveryType === 'recovery' || tokenType === 'recovery' || (accessToken && refreshToken);
if (isRecovery) {
setIsRecoveryFlow(true);
if (accessToken && refreshToken) {
supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken
}).then(({ data, error }) => {
if (!error) {
setSession(data.session);
setUser(data.session?.user || null);
setIsRecoveryFlow(true);
}
const cleanUrl = `${window.location.origin}/auth?type=recovery`;
window.history.replaceState({}, '', cleanUrl);
});
}
return true;
}
return false;
} catch {
return false;
}
};
// Marca usuário como online no sistema
const setUserOnline = async (userId: string) => {
if (!authInitialized) return;
try {
const shouldSkipLogging = await isAdminOrDeveloper(userId);
if (shouldSkipLogging) return;
try {
await supabase.rpc('set_user_online', { user_id_param: userId });
} catch {
// RPC não encontrada — continua sem erro
}
} catch (error) {
logger.error('Erro ao marcar usuário como online:', error);
}
};
const setUserOffline = async (userId: string) => {
if (!authInitialized) return;
try {
const shouldSkipLogging = await isAdminOrDeveloper(userId);
if (shouldSkipLogging) return;
try {
await supabase.rpc('set_user_offline', { user_id_param: userId });
} catch {
// RPC não encontrada — continua sem erro
}
} catch (error) {
logger.error('Erro ao marcar usuário como offline:', error);
}
};
const startSessionLog = async (userId: string) => {
if (!authInitialized) return;
try {
const shouldSkipLogging = await isAdminOrDeveloper(userId);
if (shouldSkipLogging) return;
// Desativar qualquer sessão antiga que ficou presa (fechar abas sem logout)
await supabase
.from('user_session_logs')
.update({ is_active: false, session_end: new Date().toISOString() })
.eq('user_id', userId)
.eq('is_active', true);
const { data, error } = await supabase
.from('user_session_logs')
.insert({
user_id: userId,
user_agent: navigator.userAgent,
is_active: true
})
.select()
.single();
if (error) {
logger.error('Erro ao registrar início de sessão:', error);
} else if (data) {
localStorage.setItem('currentSessionId', data.id);
}
} catch (error) {
logger.error('Erro inesperado ao registrar início de sessão:', error);
}
};
const endSessionLog = async () => {
if (!authInitialized) return;
try {
const sessionId = localStorage.getItem('currentSessionId');
if (sessionId) {
try {
await supabase.rpc('end_user_session', { session_id: sessionId });
} catch {
// RPC não encontrada — continua sem erro
}
localStorage.removeItem('currentSessionId');
}
} catch (error) {
logger.error('Erro ao finalizar sessão:', error);
}
};
useEffect(() => {
let mounted = true;
const initializeAuth = async () => {
try {
const hasRecoveryTokens = processRecoveryTokens();
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (!mounted) return;
setSession(session);
setUser(session?.user ?? null);
if (event === 'SIGNED_IN' && session?.user) {
setTimeout(() => {
if (mounted && authInitialized) {
setUserOnline(session.user.id);
startSessionLog(session.user.id);
}
}, 100);
} else if (event === 'SIGNED_OUT') {
setIsRecoveryFlow(false);
const currentUser = user;
if (currentUser && mounted && authInitialized) {
setTimeout(() => {
setUserOffline(currentUser.id);
endSessionLog();
}, 100);
}
}
}
);
if (!hasRecoveryTokens) {
try {
const { data: { session } } = await supabase.auth.getSession();
if (mounted && session?.user) {
setSession(session);
setUser(session.user);
setTimeout(() => {
if (mounted) {
setAuthInitialized(true);
setUserOnline(session.user.id);
// Sempre cria uma nova sessão, resetando contadores anteriores
startSessionLog(session.user.id);
}
}, 100);
} else if (mounted) {
setSession(null);
setUser(null);
setAuthInitialized(true);
}
} catch (sessionError) {
logger.error('Erro ao verificar sessão:', sessionError);
if (mounted) {
setSession(null);
setUser(null);
setAuthInitialized(true);
}
}
} else {
setAuthInitialized(true);
}
if (mounted) {
setLoading(false);
}
return () => {
mounted = false;
subscription.unsubscribe();
};
} catch (error) {
logger.error('Erro na inicialização da autenticação:', error);
if (mounted) {
setLoading(false);
setSession(null);
setUser(null);
setAuthInitialized(true);
}
}
};
const cleanup = initializeAuth();
return () => {
cleanup.then((cleanupFn) => {
if (cleanupFn) cleanupFn();
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Limpeza periódica de usuários offline e listener de fechamento de janela
useEffect(() => {
let cleanupInterval: NodeJS.Timeout;
if (user && authInitialized) {
cleanupInterval = setInterval(async () => {
// Enviar Heartbeat global da sessão (mantém o usuário online de fato)
const sessionId = localStorage.getItem('currentSessionId');
if (sessionId) {
try {
await supabase
.from('user_session_logs')
.update({ updated_at: new Date().toISOString() })
.eq('id', sessionId);
} catch (e) {
console.error('Falha no heartbeat da sessão', e);
}
}
try {
await supabase.rpc('cleanup_offline_users');
} catch {
// Ignora se RPC não existir
}
}, 30000); // 30 segundos
const handleBeforeUnload = () => {
if (user) {
const sessionId = localStorage.getItem('currentSessionId');
if (sessionId) {
try {
supabase.rpc('end_user_session', { session_id: sessionId });
} catch {
// Ignora no unload
}
localStorage.removeItem('currentSessionId');
}
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
if (cleanupInterval) {
clearInterval(cleanupInterval);
}
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, authInitialized]);
const signIn = async () => {
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'keycloak',
options: {
scopes: 'openid email profile',
redirectTo: window.location.origin + '/'
}
});
if (error) {
return { error };
}
return { error: null };
} catch (err) {
return { error: err as Error };
}
};
const signUp = async (email: string, password: string) => {
try {
const redirectUrl = `${window.location.origin}/`;
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: redirectUrl
}
});
return { error };
} catch (error) {
logger.error('Erro crítico no signup:', error);
return { error };
}
};
const signOut = async () => {
const currentUser = user;
if (currentUser && authInitialized) {
setTimeout(() => {
setUserOffline(currentUser.id);
endSessionLog();
}, 100);
}
setIsRecoveryFlow(false);
localStorage.removeItem('userLoginTime'); // Reseta timer do front-end
try {
await supabase.auth.signOut();
} catch (error) {
logger.error('Erro no signOut:', error);
}
};
const updatePassword = async (password: string) => {
try {
const { error } = await supabase.auth.updateUser({
password: password
});
if (!error) {
setIsRecoveryFlow(false);
}
return { error };
} catch (error) {
logger.error('Erro crítico ao atualizar senha:', error);
return { error };
}
};
const value = {
user,
session,
loading,
signIn,
signUp,
signOut,
updatePassword,
isRecoveryFlow,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+1 -249
View File
@@ -1,249 +1 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { useAuth } from './useAuth';
import { CronogramaOf } from '@/types/cronograma';
export const useCronogramaOperations = () => {
const { user } = useAuth();
const [cronogramas, setCronogramas] = useState<CronogramaOf[]>([]);
const [loading, setLoading] = useState(false);
const loadCronogramas = async () => {
try {
setLoading(true);
const { data: cronogramasData, error: cronogramasError } = await supabase
.from('cronogramas_of')
.select(`
*,
ordens_fabricacao (
num_of,
descritivo
),
profiles!cronogramas_of_gestor_id_fkey (
full_name,
email
)
`)
.order('created_at', { ascending: false });
if (cronogramasError) throw cronogramasError;
// Carregar processos para cada cronograma
const cronogramasComProcessos = await Promise.all(
(cronogramasData || []).map(async (cronograma) => {
const { data: processos, error: processosError } = await supabase
.from('processos_cronograma')
.select('*')
.eq('cronograma_id', cronograma.id)
.order('ordem');
if (processosError) throw processosError;
return {
id: cronograma.id,
of_id: cronograma.of_id,
gestor_id: cronograma.gestor_id,
revisao: cronograma.revisao,
processos: processos || [],
ordem_fabricacao: cronograma.ordens_fabricacao,
gestor_profile: cronograma.profiles
} as CronogramaOf;
})
);
setCronogramas(cronogramasComProcessos);
} catch (error) {
console.error('Erro ao carregar cronogramas:', error);
toast.error('Erro ao carregar cronogramas');
} finally {
setLoading(false);
}
};
const saveCronograma = async (cronograma: Partial<CronogramaOf> & { processos: any[] }) => {
try {
if (!user) throw new Error('Usuário não autenticado');
let cronogramaId: string;
if (cronograma.id) {
// Atualizar cronograma existente
const novaRevisao = cronograma.revisao ? cronograma.revisao + 1 : 1;
const { data: updatedCronograma, error: updateError } = await supabase
.from('cronogramas_of')
.update({
gestor_id: cronograma.gestor_id,
revisao: novaRevisao,
updated_at: new Date().toISOString()
})
.eq('id', cronograma.id)
.select()
.single();
if (updateError) throw updateError;
cronogramaId = updatedCronograma.id;
// Deletar processos antigos
const { error: deleteError } = await supabase
.from('processos_cronograma')
.delete()
.eq('cronograma_id', cronogramaId);
if (deleteError) throw deleteError;
} else {
// Verificar se já existe cronograma para esta OF
const { data: existingCronograma } = await supabase
.from('cronogramas_of')
.select('id, revisao')
.eq('of_id', cronograma.of_id)
.single();
if (existingCronograma) {
// Atualizar cronograma existente
const novaRevisao = existingCronograma.revisao + 1;
const { data: updatedCronograma, error: updateError } = await supabase
.from('cronogramas_of')
.update({
gestor_id: cronograma.gestor_id,
revisao: novaRevisao,
updated_at: new Date().toISOString()
})
.eq('id', existingCronograma.id)
.select()
.single();
if (updateError) throw updateError;
cronogramaId = updatedCronograma.id;
// Deletar processos antigos
const { error: deleteError } = await supabase
.from('processos_cronograma')
.delete()
.eq('cronograma_id', cronogramaId);
if (deleteError) throw deleteError;
} else {
// Criar novo cronograma
const { data: newCronograma, error: insertError } = await supabase
.from('cronogramas_of')
.insert({
of_id: cronograma.of_id,
gestor_id: cronograma.gestor_id,
revisao: 1,
created_by: user.id
})
.select()
.single();
if (insertError) throw insertError;
cronogramaId = newCronograma.id;
}
}
// Inserir novos processos
const processosParaInserir = cronograma.processos.map(processo => ({
cronograma_id: cronogramaId,
nome_processo: processo.nome_processo,
data_inicio: processo.data_inicio,
data_fim: processo.data_fim,
ordem: processo.ordem
}));
const { error: processosError } = await supabase
.from('processos_cronograma')
.insert(processosParaInserir);
if (processosError) throw processosError;
toast.success('Cronograma salvo com sucesso!');
loadCronogramas();
return true;
} catch (error) {
console.error('Erro ao salvar cronograma:', error);
toast.error('Erro ao salvar cronograma');
return false;
}
};
const deleteCronograma = async (cronogramaId: string) => {
try {
const { error } = await supabase
.from('cronogramas_of')
.delete()
.eq('id', cronogramaId);
if (error) throw error;
toast.success('Cronograma removido com sucesso!');
loadCronogramas();
return true;
} catch (error) {
console.error('Erro ao deletar cronograma:', error);
toast.error('Erro ao remover cronograma');
return false;
}
};
const getCronogramaPorOf = async (ofId: string) => {
try {
const { data: cronograma, error } = await supabase
.from('cronogramas_of')
.select(`
*,
ordens_fabricacao (
num_of,
descritivo
),
profiles!cronogramas_of_gestor_id_fkey (
full_name,
email
)
`)
.eq('of_id', ofId)
.single();
if (error && error.code !== 'PGRST116') throw error;
if (!cronograma) return null;
const { data: processos, error: processosError } = await supabase
.from('processos_cronograma')
.select('*')
.eq('cronograma_id', cronograma.id)
.order('ordem');
if (processosError) throw processosError;
return {
id: cronograma.id,
of_id: cronograma.of_id,
gestor_id: cronograma.gestor_id,
revisao: cronograma.revisao,
processos: processos || [],
ordem_fabricacao: cronograma.ordens_fabricacao,
gestor_profile: cronograma.profiles
} as CronogramaOf;
} catch (error) {
console.error('Erro ao buscar cronograma:', error);
return null;
}
};
useEffect(() => {
loadCronogramas();
}, []);
return {
cronogramas,
loading,
loadCronogramas,
saveCronograma,
deleteCronograma,
getCronogramaPorOf
};
};
export { useCronogramas as useCronogramaOperations } from './useCronogramas';
+47 -17
View File
@@ -47,7 +47,7 @@ export const useCronogramas = () => {
descritivo,
peso_total
),
profiles!gestor_id (
profiles!cronogramas_of_gestor_id_fkey (
full_name,
email
)
@@ -89,23 +89,50 @@ export const useCronogramas = () => {
}
};
const saveCronograma = async (cronograma: Omit<CronogramaOf, 'id'>) => {
const saveCronograma = async (cronograma: Partial<CronogramaOf> & { processos: ProcessoCronograma[] }) => {
try {
if (!user) throw new Error('Usuário não autenticado');
if (!cronograma.of_id) throw new Error('Selecione uma Ordem de Fabricação');
if (!cronograma.gestor_id) throw new Error('Selecione o gestor responsável');
// Verificar se já existe cronograma para esta OF
let cronogramaId: string;
if (cronograma.id) {
// Atualizar cronograma existente por ID
const novaRevisao = cronograma.revisao ? cronograma.revisao + 1 : 1;
const { data: updatedCronograma, error: updateError } = await supabase
.from('cronogramas_of')
.update({
gestor_id: cronograma.gestor_id,
revisao: novaRevisao,
updated_at: new Date().toISOString()
})
.eq('id', cronograma.id)
.select()
.single();
if (updateError) throw updateError;
cronogramaId = updatedCronograma.id;
// Deletar processos antigos
const { error: deleteError } = await supabase
.from('processos_cronograma')
.delete()
.eq('cronograma_id', cronogramaId);
if (deleteError) throw deleteError;
} else {
// Verificar se já existe cronograma para esta OF (usando maybeSingle para evitar erro PGRST116)
const { data: existingCronograma } = await supabase
.from('cronogramas_of')
.select('id, revisao')
.eq('of_id', cronograma.of_id)
.single();
let cronogramaId: string;
let novaRevisao = 1;
.maybeSingle();
if (existingCronograma) {
// Atualizar cronograma existente
novaRevisao = existingCronograma.revisao + 1;
const novaRevisao = (existingCronograma.revisao || 1) + 1;
const { data: updatedCronograma, error: updateError } = await supabase
.from('cronogramas_of')
@@ -144,14 +171,16 @@ export const useCronogramas = () => {
if (insertError) throw insertError;
cronogramaId = newCronograma.id;
}
}
// Inserir novos processos
const processosParaInserir = cronograma.processos.map(processo => ({
if (cronograma.processos && cronograma.processos.length > 0) {
const processosParaInserir = cronograma.processos.map((processo, idx) => ({
cronograma_id: cronogramaId,
nome_processo: processo.nome_processo,
data_inicio: processo.data_inicio,
data_fim: processo.data_fim,
ordem: processo.ordem
ordem: processo.ordem || idx + 1
}));
const { error: processosError } = await supabase
@@ -159,13 +188,15 @@ export const useCronogramas = () => {
.insert(processosParaInserir);
if (processosError) throw processosError;
}
toast.success('Cronograma salvo com sucesso!');
loadCronogramas();
await loadCronogramas();
return true;
} catch (error) {
console.error('Erro ao salvar cronograma:', error);
toast.error('Erro ao salvar cronograma');
const errMsg = error instanceof Error ? error.message : 'Erro ao salvar cronograma';
toast.error(errMsg);
return false;
}
};
@@ -180,7 +211,7 @@ export const useCronogramas = () => {
if (error) throw error;
toast.success('Cronograma removido com sucesso!');
loadCronogramas();
await loadCronogramas();
return true;
} catch (error) {
console.error('Erro ao deletar cronograma:', error);
@@ -200,16 +231,15 @@ export const useCronogramas = () => {
descritivo,
peso_total
),
profiles!gestor_id (
profiles!cronogramas_of_gestor_id_fkey (
full_name,
email
)
`)
.eq('of_id', ofId)
.single();
if (error && error.code !== 'PGRST116') throw error;
.maybeSingle();
if (error) throw error;
if (!cronograma) return null;
const { data: processos, error: processosError } = await supabase
+3 -2
View File
@@ -64,8 +64,8 @@ export const useDashboardProducao = (ofNumber: string) => {
.from('apontamentos_producao')
.select(`
*,
peca:pecas(peso_unitario),
processo:processos_fabricacao(nome, ordem)
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem)
`)
.eq('of_number', ofNumber);
@@ -172,6 +172,7 @@ export const useDashboardProducao = (ofNumber: string) => {
useEffect(() => {
fetchDashboardData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ofNumber]);
return {
+49 -21
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
@@ -44,7 +44,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
const [dashboardData, setDashboardData] = useState<DashboardDataOtimizado | null>(null);
const [loading, setLoading] = useState(false);
const fetchDashboardData = async () => {
const fetchDashboardData = useCallback(async () => {
if (!ofNumber) {
setDashboardData(null);
return;
@@ -102,6 +102,26 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
}, 0);
}
// CORREÇÃO AUTOMÁTICA: Evita que erros de digitação (ex: digitar 1.856 ao invés de 1856 kg na OF)
// causem porcentagens irreais (ex: 100000%). Compara com a soma real das peças.
if (pesoTotalPlanejado > 0) {
const { data: pecasVerificar } = await supabase.from('pecas').select('peso_unitario, quantidade').eq('of_number', ofNumber);
if (pecasVerificar && pecasVerificar.length > 0) {
const pesoBrutoPecas = pecasVerificar.reduce((sum, p) => sum + ((p.peso_unitario || 0) * (p.quantidade || 0)), 0);
if (pesoBrutoPecas > pesoTotalPlanejado * 50) {
console.log(`Corrigindo erro de digitação de peso da OF. Planejado original: ${pesoTotalPlanejado} kg, Soma das peças: ${pesoBrutoPecas} kg`);
// Se for um erro típico de 1000x (usou ponto para separar milhar)
if (pesoBrutoPecas <= pesoTotalPlanejado * 3000 && pesoBrutoPecas >= pesoTotalPlanejado * 500) {
pesoTotalPlanejado = pesoTotalPlanejado * 1000;
} else {
// Se for outro erro grosseiro, assume que o peso correto é o das peças
pesoTotalPlanejado = pesoBrutoPecas;
}
}
}
}
console.log('Peso total planejado (BASE DE TODOS OS CÁLCULOS):', pesoTotalPlanejado, 'kg');
// 4. Buscar apontamentos da OF com joins corretos
@@ -109,9 +129,9 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
.from('apontamentos_producao')
.select(`
*,
peca:pecas(peso_unitario, marca),
processo:processos_fabricacao(nome, ordem, cor),
componente:componentes_peca(peso_unitario, marca_componente)
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario, marca),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem, cor),
componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario, marca_componente)
`)
.eq('of_number', ofNumber);
@@ -158,20 +178,24 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
console.log(`PROGRESSO GERAL: ${pesoTotalFabricado} kg / ${pesoTotalPlanejado} kg = ${progressoGeral.toFixed(2)}%`);
// 7. Buscar range de datas
const { data: dateRangeData, error: dateRangeError } = await supabase
// 7. Buscar range de datas (opcional, ignora erro se a RPC não existir no Supabase)
let dateRangeData = null;
try {
const { data, error } = await supabase
.rpc('get_dashboard_date_range', { of_number_param: ofNumber });
if (dateRangeError) {
console.error('Erro ao buscar range de datas:', dateRangeError);
if (!error) dateRangeData = data;
} catch (e) {
// Ignora caso RPC não exista
}
// 8. Buscar dados consolidados
const { data: consolidatedData, error: consolidatedError } = await supabase
// 8. Buscar dados consolidados (opcional, ignora erro se a RPC não existir no Supabase)
let consolidatedData = null;
try {
const { data, error } = await supabase
.rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber });
if (consolidatedError) {
console.error('Erro ao buscar dados consolidados:', consolidatedError);
if (!error) consolidatedData = data;
} catch (e) {
// Ignora caso RPC não exista
}
// 9. Buscar processos
@@ -207,7 +231,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
if (!cronogramaOf?.processos_cronograma) return 0;
const hoje = new Date();
let processoCronograma = null;
let processoCronograma;
// Mapear nomes dos processos para os do cronograma
if (nomeProcesso.toLowerCase().includes('corte') || nomeProcesso.toLowerCase().includes('solda')) {
@@ -291,7 +315,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
const progressoEsperado = calcularProgressoEsperado(processo.nome);
// Determinar status baseado na comparação entre progresso real e esperado
let status: 'verde' | 'amarelo' | 'vermelho' | 'azul' = 'verde';
let status: 'verde' | 'amarelo' | 'vermelho' | 'azul';
// Se o progresso esperado chegou a 100% (passou da data fim), usar cor vermelha na barra
if (progressoEsperado >= 100) {
@@ -330,7 +354,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
let dataFimProcesso: Date | null = null;
if (cronogramaOf?.processos_cronograma) {
let processoCronograma = null;
let processoCronograma;
// Mapear nomes dos processos para os do cronograma
if (processo.nome.toLowerCase().includes('corte') || processo.nome.toLowerCase().includes('solda')) {
@@ -406,7 +430,11 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
// Antes do início planejado, o planejado fica 0
// Calcular progresso real até esta data - soma dos pesos apontados
const apontamentosAteData = apontamentosProcesso.filter(a => a.data_apontamento <= dataStr);
const apontamentosAteData = apontamentosProcesso.filter(a => {
if (!a.data_apontamento) return false;
const dataAptStr = typeof a.data_apontamento === 'string' ? a.data_apontamento.substring(0, 10) : '';
return dataAptStr <= dataStr;
});
let realizado = 0;
apontamentosAteData.forEach(a => {
let pesoUnitario = 0;
@@ -524,11 +552,11 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
} finally {
setLoading(false);
}
};
}, [ofNumber]);
useEffect(() => {
fetchDashboardData();
}, [ofNumber]);
}, [fetchDashboardData]);
return {
dashboardData,
+4 -7
View File
@@ -77,6 +77,7 @@ export interface FichaTecnicaData {
doc_catalogo?: boolean;
doc_fotos?: boolean;
/* eslint-disable @typescript-eslint/no-explicit-any */
// Informações do projeto
info_calculo_estrutural?: any;
info_projeto_basico?: any;
@@ -144,6 +145,7 @@ export interface FichaTecnicaData {
visto_exp?: string;
visto_qual?: string;
visto_colunas?: any;
/* eslint-enable @typescript-eslint/no-explicit-any */
}
export function useFichaTecnica() {
@@ -160,7 +162,6 @@ export function useFichaTecnica() {
const { data, error } = await supabase
.from('ficha_tecnica_contratos')
.select('*')
.eq('user_id', user.id)
.order('of_number', { ascending: true });
if (error) {
@@ -182,7 +183,6 @@ export function useFichaTecnica() {
.from('ficha_tecnica_contratos')
.select('*')
.eq('of_number', ofNumber.trim())
.eq('user_id', user.id)
.maybeSingle();
if (error) {
@@ -373,8 +373,7 @@ export function useFichaTecnica() {
const { error } = await supabase
.from('ficha_tecnica_contratos')
.update(dataToSave)
.eq('id', data.id)
.eq('user_id', user.id);
.eq('id', data.id);
if (error) {
console.error('Erro ao atualizar ficha técnica:', error);
@@ -389,7 +388,6 @@ export function useFichaTecnica() {
.from('ficha_tecnica_contratos')
.select('id')
.eq('of_number', data.of_number.trim())
.eq('user_id', user.id)
.maybeSingle();
if (existingFicha) {
@@ -397,8 +395,7 @@ export function useFichaTecnica() {
const { error } = await supabase
.from('ficha_tecnica_contratos')
.update(dataToSave)
.eq('id', existingFicha.id)
.eq('user_id', user.id);
.eq('id', existingFicha.id);
if (error) {
console.error('Erro ao atualizar ficha técnica existente:', error);
+4 -3
View File
@@ -209,16 +209,17 @@ export function useInterfaceResources() {
.from('profiles')
.select('privilege_id')
.eq('id', userId)
.single();
.limit(1);
if (profileError) throw profileError;
if (!profile?.privilege_id) return [];
const priv = profile && profile.length > 0 ? profile[0].privilege_id : null;
if (!priv) return [];
const { data, error } = await supabase
.from('privilege_interface_resources')
.select('resource_key')
.eq('privilege_id', profile.privilege_id);
.eq('privilege_id', priv);
if (error) throw error;
+8 -8
View File
@@ -44,14 +44,14 @@ export const useItensPrioridadeFabricacao = () => {
.from('itens_prioridade_fabricacao')
.select(`
*,
peca:pecas(marca, descricao, peso_unitario, quantidade, tem_componentes, of_number, etapa_fase),
prioridade_fabricacao:prioridades_fabricacao(
peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(marca, descricao, peso_unitario, quantidade, tem_componentes, of_number, etapa_fase),
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(
of_number,
etapa_fase,
revisao,
data_ultima_modificacao,
modificado_por,
prioridade_config:prioridades_config(codigo, nome, cor)
prioridade_config:prioridades_config!prioridades_fabricacao_prioridade_id_fkey(codigo, nome, cor)
)
`)
.order('ordem_fabricacao', { ascending: true });
@@ -124,8 +124,8 @@ export const useItensPrioridadeFabricacao = () => {
.from('itens_prioridade_fabricacao')
.select(`
*,
peca:pecas(of_number, etapa_fase, marca),
prioridade_fabricacao:prioridades_fabricacao(of_number, etapa_fase)
peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(of_number, etapa_fase, marca),
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(of_number, etapa_fase)
`)
.eq('id', itemId)
.single();
@@ -215,8 +215,8 @@ export const useItensPrioridadeFabricacao = () => {
.select(`
id,
prioridade_fabricacao_id,
peca:pecas(of_number, etapa_fase, marca),
prioridade_fabricacao:prioridades_fabricacao(of_number, etapa_fase)
peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(of_number, etapa_fase, marca),
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(of_number, etapa_fase)
`)
.in('id', idsParaRemover);
@@ -341,7 +341,7 @@ export const useItensPrioridadeFabricacao = () => {
.from('itens_prioridade_fabricacao')
.select(`
*,
prioridade_fabricacao:prioridades_fabricacao(
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(
of_number,
etapa_fase
)
@@ -54,7 +54,7 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
.select(`
revisao,
data_ultima_modificacao,
profiles:modificado_por(full_name)
profiles:prioridades_fabricacao_modificado_por_profiles_fkey(full_name)
`)
.eq('of_number', ofSelecionada)
.eq('etapa_fase', faseSelecionada)
@@ -71,7 +71,7 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
setVersaoAtual({
revisao: data.revisao || 0,
dataModificacao: data.data_ultima_modificacao || new Date().toISOString(),
modificadoPor: (data.profiles as any)?.full_name
modificadoPor: (data.profiles as { full_name?: string } | null)?.full_name
});
} else {
setVersaoAtual({
@@ -92,12 +92,49 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
setFaseSelecionada(novaFase);
};
const incrementarRevisao = async () => {
if (!ofSelecionada || !faseSelecionada) return false;
try {
const novaRevisao = (versaoAtual?.revisao || 0) + 1;
const dataModificacao = new Date().toISOString();
const { error } = await supabase
.from('prioridades_fabricacao')
.update({
revisao: novaRevisao,
data_ultima_modificacao: dataModificacao
})
.eq('of_number', ofSelecionada)
.eq('etapa_fase', faseSelecionada);
if (error) {
console.error('Erro ao incrementar revisão no DB:', error);
toast.error('Erro ao registrar nova revisão.');
return false;
}
setVersaoAtual(prev => ({
...prev!,
revisao: novaRevisao,
dataModificacao: dataModificacao
}));
toast.success(`Revisão atualizada para ${novaRevisao}`);
return true;
} catch (error) {
console.error('Erro ao incrementar revisão:', error);
return false;
}
};
return {
...hookOriginal,
itensPorPrioridade: itensFiltrados,
ofSelecionada,
faseSelecionada,
versaoAtual,
onFiltroChange: handleFiltroChange
onFiltroChange: handleFiltroChange,
incrementarRevisao
};
};
+17 -5
View File
@@ -14,17 +14,29 @@ export const useOFsAtivas = () => {
queryKey: ['ofs-ativas'],
queryFn: async () => {
const { data, error } = await supabase
.from('ficha_tecnica_contratos')
.select('of_number, descricao_resumida, cliente, gestor')
.not('of_number', 'is', null)
.order('of_number');
.from('ordens_fabricacao')
.select(`
num_of,
descritivo,
gestor,
ficha_tecnica_contratos (
cliente
)
`)
.eq('status', 'ativa')
.order('num_of');
if (error) {
console.error('Erro ao buscar OFs ativas:', error);
throw error;
}
return data as OFAtiva[];
return data.map((of: { num_of: string, descritivo?: string | null, gestor?: string | null, ficha_tecnica_contratos?: { cliente?: string | null } | null }) => ({
of_number: of.num_of,
descricao_resumida: of.descritivo,
gestor: of.gestor,
cliente: of.ficha_tecnica_contratos?.cliente
})) as OFAtiva[];
},
});
+16 -71
View File
@@ -1,90 +1,35 @@
// Password reset usando Logto (substitui supabase.auth.resetPasswordForEmail)
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { requestPasswordReset as logtoRequestPasswordReset } from '@/lib/logto/client';
import { toast } from 'sonner';
export const usePasswordReset = () => {
const queryClient = useQueryClient();
// Mutation para solicitar redefinição de senha
const requestPasswordReset = useMutation({
mutationFn: async ({ email }: { email: string }) => {
console.log('🔄 Solicitando redefinição de senha para:', email);
console.log('🔄 [Logto] Solicitando redefinição de senha para:', email);
// Detectar o domínio atual e configurar URL de redirecionamento
const currentHostname = window.location.hostname;
let redirectUrl = '';
if (currentHostname.includes('preview--tracksteel.lovable.app') ||
currentHostname.includes('lovableproject.com') ||
currentHostname.includes('lovable.app')) {
// Ambiente de preview do Lovable
redirectUrl = `${window.location.origin}/auth?type=recovery`;
} else if (currentHostname.includes('tracksteel.com.br')) {
// Domínio customizado
redirectUrl = `https://app.tracksteel.com.br/auth?type=recovery`;
} else {
// Fallback para localhost ou outros casos
redirectUrl = `${window.location.origin}/auth?type=recovery`;
}
console.log('📍 URL de redirecionamento configurada:', redirectUrl);
// Enviar e-mail de redefinição usando o Supabase Auth
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: redirectUrl,
});
if (resetError) {
console.error('❌ Erro ao solicitar redefinição:', resetError);
throw resetError;
}
console.log('✅ E-mail de redefinição enviado com sucesso para:', email);
// Registrar a solicitação no banco de dados (opcional, não bloquear se falhar)
try {
const { error: insertError } = await supabase
.from('password_reset_requests')
.insert({
email,
user_id: null,
ip_address: null,
user_agent: navigator.userAgent,
});
if (insertError) {
console.warn('⚠️ Falha ao registrar solicitação:', insertError);
} else {
console.log('📝 Solicitação registrada no banco de dados');
}
} catch (logError) {
console.warn('⚠️ Erro no log da solicitação:', logError);
const result = await logtoRequestPasswordReset(email);
if (!result.ok) {
throw new Error(result.error || 'Falha ao solicitar reset');
}
console.log('✅ [Logto] E-mail de redefinição processado para:', email);
return { success: true };
},
onSuccess: () => {
toast.success('E-mail de redefinição enviado com sucesso! Verifique sua caixa de entrada e clique no link recebido.');
toast.success(
'Se o e-mail estiver cadastrado, você receberá um link para redefinir sua senha em alguns minutos.'
);
queryClient.invalidateQueries({ queryKey: ['password-reset-requests'] });
},
onError: (error: any) => {
console.error('❌ Erro na solicitação de redefinição:', error);
// Tratamento específico de erros
let errorMessage = 'Erro ao solicitar redefinição de senha. Tente novamente.';
if (error.message?.includes('User not found')) {
errorMessage = 'E-mail não encontrado no sistema. Verifique se o e-mail está correto.';
} else if (error.message?.includes('rate limit') || error.message?.includes('Email rate limit exceeded')) {
errorMessage = 'Muitas tentativas de redefinição. Aguarde alguns minutos antes de tentar novamente.';
} else if (error.message?.includes('signup_disabled')) {
errorMessage = 'Funcionalidade temporariamente indisponível. Contate o administrador.';
} else if (error.message) {
errorMessage = `Erro: ${error.message}`;
}
toast.error(errorMessage);
onError: (error: Error) => {
console.error('❌ Erro ao solicitar reset:', error);
// Não revela se o email existe (segurança)
toast.error(
'Não foi possível enviar o e-mail. Tente novamente em alguns minutos.'
);
},
});
+2 -2
View File
@@ -363,8 +363,8 @@ export function usePecas() {
marca: item.marca || '',
descricao: item.descricao || '',
quantidade: Number(item.quantidade) || 0,
peso_unitario: Math.round(Number(item.peso_unitario) || 0),
peso_total: Math.round(Number(item.peso_total) || 0),
peso_unitario: Number(item.peso_unitario) || 0,
peso_total: Number(item.peso_total) || 0,
tratamento_superficial: item.tratamento_superficial || '',
material: item.material || '',
perfil_principal: item.perfil_principal || '',
+4 -1
View File
@@ -177,7 +177,10 @@ export const usePecasTable = (pecas: Peca[]) => {
}
return pecasParaCalcular.reduce((total, peca) => {
return total + (peca.peso_total || 0);
const pTotal = (peca.peso_total && peca.peso_total > 0)
? peca.peso_total
: (peca.quantidade || 1) * (peca.peso_unitario || 0);
return total + pTotal;
}, 0);
}, [filteredAndSortedPecas, selectedPecas]);
+53 -26
View File
@@ -44,51 +44,42 @@ export const useProcessChartData = (ofNumber: string) => {
console.log(`Processo ${processName} encontrado com ID:`, processo.id);
const hoje = new Date();
hoje.setHours(23, 59, 59, 999);
const seteDiasAtras = new Date(hoje);
seteDiasAtras.setDate(hoje.getDate() - 7);
seteDiasAtras.setHours(0, 0, 0, 0);
const quatorzeDiasAtras = new Date(hoje);
quatorzeDiasAtras.setDate(hoje.getDate() - 14);
quatorzeDiasAtras.setHours(0, 0, 0, 0);
const seisDiasAtras = new Date(hoje);
seisDiasAtras.setDate(hoje.getDate() - 6);
seisDiasAtras.setHours(0, 0, 0, 0);
// Buscar apontamentos dos últimos 14 dias
// Buscar apontamentos completos para obter o total fabricado
const { data: apontamentos, error: apontamentosError } = await supabase
.from('apontamentos_producao')
.select(`
*,
peca:pecas(peso_unitario),
componente:componentes_peca(peso_unitario)
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario)
`)
.eq('of_number', ofNumber)
.eq('processo_id', processo.id)
.gte('data_apontamento', quatorzeDiasAtras.toISOString().split('T')[0])
.lte('data_apontamento', hoje.toISOString().split('T')[0]);
.eq('processo_id', processo.id);
if (apontamentosError) {
console.error(`Erro ao buscar apontamentos para ${processName}:`, apontamentosError);
return [
{ name: '7-15d', weight: 0 },
{ name: 'ult.7d', weight: 0 },
{ name: 'prev.7d', weight: 0 }
];
}
console.log(`Apontamentos encontrados para ${processName}:`, apontamentos?.length || 0);
if (!apontamentos || apontamentos.length === 0) {
return [
{ name: '7-15d', weight: 0 },
{ name: 'ult.7d', weight: 0 },
{ name: 'prev.7d', weight: 0 }
];
}
// Calcular pesos por período
let peso7a14Dias = 0;
let peso6DiasRecentes = 0;
let pesoTotalProcessado = 0;
if (apontamentos && apontamentos.length > 0) {
apontamentos.forEach(apontamento => {
let pesoUnitario = 0;
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
@@ -98,7 +89,15 @@ export const useProcessChartData = (ofNumber: string) => {
}
const pesoTotal = pesoUnitario * apontamento.quantidade_produzida;
const dataApontamento = new Date(apontamento.data_apontamento);
pesoTotalProcessado += pesoTotal;
if (apontamento.data_apontamento) {
// Se data_apontamento for apenas a data (YYYY-MM-DD), setamos pra meio dia pra evitar bugs de fuso
const dateStr = typeof apontamento.data_apontamento === 'string' && apontamento.data_apontamento.length <= 10
? apontamento.data_apontamento + 'T12:00:00'
: apontamento.data_apontamento;
const dataApontamento = new Date(dateStr);
// Período de 7-14 dias atrás
if (dataApontamento >= quatorzeDiasAtras && dataApontamento < seteDiasAtras) {
@@ -108,7 +107,9 @@ export const useProcessChartData = (ofNumber: string) => {
if (dataApontamento >= seisDiasAtras && dataApontamento <= hoje) {
peso6DiasRecentes += pesoTotal;
}
}
});
}
// Calcular peso necessário para próximos 7 dias (meta)
const { data: ofData } = await supabase
@@ -117,12 +118,38 @@ export const useProcessChartData = (ofNumber: string) => {
.eq('num_of', ofNumber)
.single();
let pesoTotalPlanejado = ofData?.peso_total || 0;
if (!pesoTotalPlanejado) {
const { data: fichaTecnica } = await supabase
.from('ficha_tecnica_contratos')
.select('quantidade')
.eq('of_number', ofNumber)
.maybeSingle();
if (fichaTecnica?.quantidade) {
pesoTotalPlanejado = fichaTecnica.quantidade;
}
}
if (!pesoTotalPlanejado) {
const { data: pecasData } = await supabase
.from('pecas')
.select('peso_unitario, quantidade')
.eq('of_number', ofNumber);
if (pecasData) {
pesoTotalPlanejado = pecasData.reduce((total, peca) => {
return total + ((peca.peso_unitario || 0) * (peca.quantidade || 0));
}, 0);
}
}
let pesoMeta = 0;
if (ofData?.peso_total && ofData?.data_prazo) {
const dataFim = new Date(ofData.data_prazo);
if (pesoTotalPlanejado > 0) {
const dataFim = ofData?.data_prazo ? new Date(ofData.data_prazo + 'T23:59:59') : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
const diasRestantes = Math.max(1, Math.ceil((dataFim.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24)));
const pesoTotalProcessado = peso7a14Dias + peso6DiasRecentes;
const pesoRestante = Math.max(0, ofData.peso_total - pesoTotalProcessado);
const pesoRestante = Math.max(0, pesoTotalPlanejado - pesoTotalProcessado);
// Meta para próximos 7 dias baseada no ritmo necessário
if (diasRestantes <= 7) {
@@ -133,7 +160,7 @@ export const useProcessChartData = (ofNumber: string) => {
}
const resultado = [
{ name: '7-15d', weight: Number(peso7a14Dias.toFixed(0)) }, // Mantido em Kg
{ name: '7-15d', weight: Number(peso7a14Dias.toFixed(0)) },
{ name: 'ult.7d', weight: Number(peso6DiasRecentes.toFixed(0)) },
{ name: 'prev.7d', weight: Number(pesoMeta.toFixed(0)) }
];
+4 -3
View File
@@ -45,20 +45,20 @@ export const useRelatorioDiario = (selectedDate: string) => {
data_apontamento,
created_at,
tipo_apontamento,
peca:pecas(
peca:pecas!apontamentos_producao_peca_id_fkey(
id,
marca,
descricao,
etapa_fase,
peso_unitario
),
componente:componentes_peca(
componente:componentes_peca!apontamentos_producao_componente_id_fkey(
id,
marca_componente,
descricao,
peso_unitario
),
processo:processos_fabricacao(
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(
nome
)
`)
@@ -136,6 +136,7 @@ export const useRelatorioDiario = (selectedDate: string) => {
useEffect(() => {
fetchApontamentos();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDate]);
// Calcular resumo por processo
+1 -1
View File
@@ -94,7 +94,7 @@ export const useRelatorioPecasProcesso = (ofNumber: string) => {
peca_id,
processo_id,
quantidade_produzida,
processos_fabricacao!inner(nome)
processos_fabricacao!apontamentos_producao_processo_id_fkey(nome)
`)
.eq('of_number', ofNumber)
.in('peca_id', pecaIds);
+18 -3
View File
@@ -296,12 +296,26 @@ export const useSessionLogs = () => {
}
};
// Atualizar automaticamente usuários online
// Atualizar automaticamente usuários online e fazer heartbeat da sessão
useEffect(() => {
fetchOnlineUsers();
const interval = setInterval(fetchOnlineUsers, 30000); // Atualizar a cada 30 segundos
const interval = setInterval(() => {
fetchOnlineUsers();
const sessionId = currentSessionId || localStorage.getItem('currentSessionId');
if (sessionId) {
// Heartbeat pings database every 30s to keep session alive
supabase
.from('user_session_logs')
.update({ updated_at: new Date().toISOString() })
.eq('id', sessionId)
.then(({ error }) => {
if (error) console.error('Heartbeat error:', error);
});
}
}, 30000); // Atualizar a cada 30 segundos
return () => clearInterval(interval);
}, []);
}, [currentSessionId]);
// Gerenciar sessão baseado no estado de autenticação
useEffect(() => {
@@ -310,6 +324,7 @@ export const useSessionLogs = () => {
} else if (!user && currentSessionId) {
endSession();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
// Finalizar sessão quando a janela é fechada
+23 -9
View File
@@ -4,9 +4,9 @@ import { supabase } from '@/integrations/supabase/client';
import { Database } from '@/integrations/supabase/types';
import { toast } from 'sonner';
type Task = Database['public']['Tables']['tasks']['Row'];
type TaskInsert = Database['public']['Tables']['tasks']['Insert'];
type TaskUpdate = Database['public']['Tables']['tasks']['Update'];
type Task = Database['TS_ERP']['Tables']['tasks']['Row'];
type TaskInsert = Database['TS_ERP']['Tables']['tasks']['Insert'];
type TaskUpdate = Database['TS_ERP']['Tables']['tasks']['Update'];
export const useTasks = () => {
const queryClient = useQueryClient();
@@ -182,17 +182,28 @@ export const useTasks = () => {
queryFn: async () => {
console.log('🔍 Fetching available OFs...');
const { data, error } = await supabase
.from('ficha_tecnica_contratos')
.select('of_number, cliente')
.order('of_number');
.from('ordens_fabricacao')
.select(`
num_of,
ficha_tecnica_contratos (
cliente
)
`)
.eq('status', 'ativa')
.order('num_of');
if (error) {
console.error('❌ Error fetching OFs:', error);
throw error;
}
console.log('✅ Available OFs fetched:', data?.length || 0);
return data || [];
const mappedData = data?.map((of: { num_of: string, ficha_tecnica_contratos?: { cliente?: string | null } | null }) => ({
of_number: of.num_of,
cliente: of.ficha_tecnica_contratos?.cliente
}));
console.log('✅ Available OFs fetched:', mappedData?.length || 0);
return mappedData || [];
},
});
@@ -213,6 +224,7 @@ export const useTasks = () => {
try {
// Use RPC function to bypass RLS restrictions for user listing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await supabase.rpc('get_all_users_for_tasks' as any);
if (error) {
@@ -248,7 +260,9 @@ export const useTasks = () => {
if (fallbackError) {
console.error('❌ Error fetching users (fallback):', fallbackError);
throw new Error('Erro ao buscar usuários: ' + fallbackError.message);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - TS expects 1 arg, eslint expects 2
throw new Error('Erro ao buscar usuários: ' + fallbackError.message, { cause: rpcError as Error });
}
console.log('✅ Available users fetched (fallback):', fallbackData?.length || 0);
+24 -8
View File
@@ -11,6 +11,7 @@ export const useTasksEnhanced = () => {
const { user } = useAuth();
// Helper function to apply filters to a query
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const applyFilters = (query: any, filters: TaskFilters) => {
if (filters.of_number) {
query = query.eq('of_number', filters.of_number);
@@ -33,14 +34,16 @@ export const useTasksEnhanced = () => {
if (filters.due_date_range) {
const now = new Date();
switch (filters.due_date_range) {
case 'week':
case 'week': {
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
query = query.gte('due_date', now.toISOString()).lte('due_date', nextWeek.toISOString());
break;
case 'month':
}
case 'month': {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate());
query = query.gte('due_date', now.toISOString()).lte('due_date', nextMonth.toISOString());
break;
}
case 'overdue':
query = query.lt('due_date', now.toISOString());
break;
@@ -276,7 +279,9 @@ export const useTasksEnhanced = () => {
if (fallbackError) {
console.error('❌ Error fetching users (fallback):', fallbackError);
throw new Error('Erro ao buscar usuários: ' + fallbackError.message);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - TS expects 1 arg, eslint expects 2
throw new Error('Erro ao buscar usuários: ' + fallbackError.message, { cause: fallbackError });
}
console.log('✅ Available users fetched (fallback):', fallbackData?.length || 0);
@@ -316,17 +321,28 @@ export const useTasksEnhanced = () => {
if (!user) throw new Error('Usuário não autenticado');
const { data, error } = await supabase
.from('ficha_tecnica_contratos')
.select('of_number, cliente')
.order('of_number');
.from('ordens_fabricacao')
.select(`
num_of,
ficha_tecnica_contratos (
cliente
)
`)
.eq('status', 'ativa')
.order('num_of');
if (error) {
console.error('❌ Error fetching OFs:', error);
throw error;
}
console.log('✅ Available OFs fetched:', data?.length || 0);
return data || [];
const mappedData = data?.map((of: { num_of: string, ficha_tecnica_contratos?: { cliente?: string | null } | null }) => ({
of_number: of.num_of,
cliente: of.ficha_tecnica_contratos?.cliente
}));
console.log('✅ Available OFs fetched:', mappedData?.length || 0);
return mappedData || [];
},
enabled: !!user,
});
+42 -11
View File
@@ -22,7 +22,7 @@ export interface UserProfile {
privileges?: {
name: string;
description: string;
permissions: any;
permissions: Record<string, unknown>;
};
}
@@ -38,7 +38,7 @@ export interface UserPrivilege {
id: string;
name: string;
description: string | null;
permissions: any;
permissions: Record<string, unknown>;
created_at: string;
updated_at: string;
}
@@ -51,6 +51,33 @@ export interface UserDependency {
}
export function useUserManagement() {
const deleteUserAndLogtoAccount = async (userId: string, email: string) => {
try {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
// 1. Deleta do Logto primeiro
const response = await fetch(`${supabaseUrl}/functions/v1/delete-logto-user`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
console.error("Failed to delete from Logto", await response.text());
toast.error("Erro ao excluir do Logto. A exclusão no sistema continuará.");
}
// 2. Deleta do Supabase
return await deleteUser(userId, true);
} catch (err) {
console.error(err);
toast.error("Erro na comunicação com o servidor de autenticação");
return false;
}
};
const { user } = useAuth();
const [users, setUsers] = useState<UserProfile[]>([]);
const [pendingUsers, setPendingUsers] = useState<UserProfile[]>([]);
@@ -171,6 +198,7 @@ export function useUserManagement() {
try {
const { data: result, error } = await supabase.rpc('admin_create_user', {
user_email: data.email,
_caller_id: user?.id,
user_full_name: data.full_name || null,
user_function_id: data.function_id || null,
user_privilege_id: data.privilege_id || null
@@ -181,10 +209,11 @@ export function useUserManagement() {
toast.success('Usuário criado com sucesso! Senha padrão: 1234');
fetchUsers();
return result;
} catch (error: any) {
} catch (error: unknown) {
console.error('Error creating user:', error);
if (error.message?.includes('User with this email already exists')) {
const err = error as Error;
if (err.message?.includes('User with this email already exists')) {
toast.error('Este e-mail já está em uso');
} else if (error.message?.includes('Only admins can create new users')) {
toast.error('Apenas administradores podem criar usuários');
@@ -230,6 +259,7 @@ export function useUserManagement() {
}
const { data, error } = await supabase.rpc('admin_delete_user', {
_caller_id: user?.id,
_user_id: userId
});
@@ -239,12 +269,13 @@ export function useUserManagement() {
fetchUsers();
fetchPendingUsers();
return true;
} catch (error: any) {
} catch (error: unknown) {
console.error('Error deleting user:', error);
if (error.message?.includes('Only admins can delete users')) {
const err = error as Error;
if (err.message?.includes('Only admins can delete users')) {
toast.error('Apenas administradores podem excluir usuários');
} else if (error.message?.includes('User cannot be deleted due to existing dependencies')) {
} else if (err.message?.includes('User cannot be deleted due to existing dependencies')) {
toast.error('Este usuário não pode ser excluído pois possui dados vinculados no sistema');
} else {
toast.error('Erro ao excluir usuário');
@@ -309,10 +340,9 @@ export function useUserManagement() {
}
};
// Toggle user status
const toggleUserStatus = async (userId: string, currentStatus: string) => {
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
await updateUser(userId, { status: newStatus as any });
await updateUser(userId, { status: newStatus as UserProfile['status'] });
};
// CRUD functions for Functions table
@@ -367,7 +397,7 @@ export function useUserManagement() {
};
// CRUD functions for Privileges table - Updated to handle interface resources
const createPrivilege = async (data: { name: string; description?: string; permissions?: any }, resourceKeys: string[] = []) => {
const createPrivilege = async (data: { name: string; description?: string; permissions?: Record<string, unknown> }, resourceKeys: string[] = []) => {
try {
const { data: newPrivilege, error } = await supabase
.from('privileges')
@@ -395,7 +425,7 @@ export function useUserManagement() {
}
};
const updatePrivilege = async (id: string, data: { name: string; description?: string; permissions?: any }) => {
const updatePrivilege = async (id: string, data: { name: string; description?: string; permissions?: Record<string, unknown> }) => {
try {
const { error } = await supabase
.from('privileges')
@@ -458,6 +488,7 @@ export function useUserManagement() {
updateUser,
toggleUserStatus,
deleteUser,
deleteUserAndLogtoAccount,
canDeleteUser,
getUserDependencies,
replaceUserWithDeleted,
@@ -2,7 +2,6 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from 'sonner';
import type {
UserPermissions,
@@ -14,7 +13,6 @@ import type {
export function useUserPermissions() {
const { user } = useAuth();
const { isAdmin } = useUserRole();
const [userPermissions, setUserPermissions] = useState<UserPermissions>({
can_admin: false,
can_create_update_delete: false,
@@ -88,10 +86,9 @@ export function useUserPermissions() {
loadUserPermissions();
}, [loadUserPermissions]);
// Check if user has access to a resource
const hasAccess = useCallback((resourceKey?: string): boolean => {
// Admin always has access
if (isAdmin) {
if (userPermissions.can_admin) {
console.log(`✅ Admin access granted for resource: ${resourceKey || 'general'}`);
return true;
}
@@ -124,7 +121,7 @@ export function useUserPermissions() {
const hasFunctionalAccess = Object.values(userPermissions).some(Boolean);
console.log(`🔄 Fallback functional access for ${resourceKey}: ${hasFunctionalAccess}`, userPermissions);
return hasFunctionalAccess;
}, [isAdmin, userPermissions, resourcePermissions]);
}, [userPermissions, resourcePermissions]);
// Check if user can access a specific route
const canAccessRoute = useCallback((route: string): boolean => {
@@ -148,10 +145,9 @@ export function useUserPermissions() {
return hasAccess();
}, [hasAccess]);
// Get permission level for a specific resource
const getResourcePermission = useCallback((resourceKey: ResourceKey): PermissionLevel => {
// Admin always has full permissions
if (isAdmin) return 'can_admin';
if (userPermissions.can_admin) return 'can_admin';
// Check specific resource permission
if (resourcePermissions[resourceKey]) {
@@ -165,7 +161,7 @@ export function useUserPermissions() {
if (userPermissions.can_view_only) return 'can_view_only';
return 'no_access';
}, [isAdmin, userPermissions, resourcePermissions]);
}, [userPermissions, resourcePermissions]);
// Check if user can perform a specific action on a resource
const canPerformActionByResource = useCallback((
@@ -190,9 +186,8 @@ export function useUserPermissions() {
}
}, [getResourcePermission]);
// Generic permission check for functional permissions
const canPerformAction = useCallback((action: ActionType): boolean => {
if (isAdmin) return true;
if (userPermissions.can_admin) return true;
switch (action) {
case 'admin':
@@ -208,7 +203,7 @@ export function useUserPermissions() {
default:
return false;
}
}, [isAdmin, userPermissions]);
}, [userPermissions]);
// Set specific resource permission
const setResourcePermission = useCallback(async (
+9 -5
View File
@@ -7,6 +7,7 @@ interface UserProfile {
full_name: string | null;
email: string | null;
profile_image_url: string | null;
status: string | null;
}
export function useUserProfile() {
@@ -21,6 +22,7 @@ export function useUserProfile() {
setProfile(null);
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
const fetchUserProfile = async () => {
@@ -30,7 +32,7 @@ export function useUserProfile() {
try {
const { data, error } = await supabase
.from('profiles')
.select('full_name, email, profile_image_url')
.select('full_name, email, profile_image_url, status')
.eq('id', user.id)
.maybeSingle();
@@ -39,18 +41,20 @@ export function useUserProfile() {
} else {
// Fallback para dados do usuário auth
setProfile({
full_name: user.user_metadata?.full_name || null,
full_name: user.name || user.username || null,
email: user.email || null,
profile_image_url: null
profile_image_url: null,
status: null
});
}
} catch (error) {
console.error('Erro ao buscar perfil do usuário:', error);
// Fallback para dados do usuário auth
setProfile({
full_name: user.user_metadata?.full_name || null,
full_name: user.name || user.username || null,
email: user.email || null,
profile_image_url: null
profile_image_url: null,
status: null
});
} finally {
setLoading(false);
+9 -5
View File
@@ -18,6 +18,7 @@ export function useUserResourcePermissions(resourceKey: string) {
const [users, setUsers] = useState<UserWithProfile[]>([]);
const [resourcePermissions, setResourcePermissions] = useState<UserInterfacePermission[]>([]);
const [loading, setLoading] = useState(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const channelRef = useRef<any>(null);
const mountedRef = useRef(true);
@@ -66,19 +67,20 @@ export function useUserResourcePermissions(resourceKey: string) {
// Enriquecer com dados do usuário
const enrichedData = await Promise.all(
(data || []).map(async (permission) => {
const { data: profileData } = await supabase
const { data: profileDataArr } = await supabase
.from('profiles')
.select('email, full_name')
.eq('id', permission.user_id)
.single();
.limit(1);
const profileData = profileDataArr && profileDataArr.length > 0 ? profileDataArr[0] : null;
return {
user_id: permission.user_id,
resource_key: permission.resource_key,
permission_level: permission.permission as PermissionLevel,
created_at: permission.created_at,
updated_at: permission.updated_at,
profiles: profileData
profiles: profileData as any
} as UserInterfacePermission;
})
);
@@ -166,12 +168,13 @@ export function useUserResourcePermissions(resourceKey: string) {
}
// Verify user exists
const { data: userExists, error: userError } = await supabase
const { data: userExistsArr, error: userError } = await supabase
.from('profiles')
.select('id, email')
.eq('id', userId)
.single();
.limit(1);
const userExists = userExistsArr && userExistsArr.length > 0 ? userExistsArr[0] : null;
if (userError || !userExists) {
console.error('User not found:', { userId, userError });
toast.error('Usuário não encontrado');
@@ -341,6 +344,7 @@ export function useUserResourcePermissions(resourceKey: string) {
mountedRef.current = false;
cleanupChannel();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, isAdmin, resourceKey]);
// Cleanup on unmount
+23 -61
View File
@@ -1,84 +1,46 @@
// Hook que retorna role/permissões do usuário
// Migrado pra consumir as permissões reais do banco via useUserPermissions
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useUserPermissions } from '@/hooks/useUserPermissions';
import type { UserRole, AppRole } from '@/hooks/useUserPermissions/types';
export type AccessLevel = 'Total' | 'Parcial' | 'Restrita';
export const useUserRole = () => {
const { user } = useAuth();
const { user, loading: authLoading } = useAuth();
const { userPermissions, loading: permsLoading } = useUserPermissions();
const query = useQuery({
queryKey: ['user-role', user?.id],
queryFn: async () => {
if (!user?.id) {
const loading = authLoading || permsLoading;
// Sem usuário = sem permissão
if (!user) {
return {
accessLevel: 'Restrita' as AccessLevel,
isAdmin: false,
isGerencia: false,
isDiretoria: false,
role: 'user' as UserRole
role: 'user' as UserRole,
loading,
error: null,
};
}
try {
const { data: profile, error } = await supabase
.from('profiles')
.select('id, full_name')
.eq('id', user.id)
.single();
if (error || !profile) {
return {
accessLevel: 'Restrita' as AccessLevel,
isAdmin: false,
isGerencia: false,
isDiretoria: false,
role: 'user' as UserRole
};
}
// For now, return default permissions until we have proper role system
// This can be enhanced later with actual role checking
const accessLevel = 'Total' as AccessLevel;
const isAdmin = true; // Temporary - should be based on actual roles
// Com usuário autenticado + validações do json de privilégios do DB
const isAdminCheck = Boolean(userPermissions.can_admin);
return {
accessLevel,
isAdmin: true,
isGerencia: false,
isDiretoria: false,
role: 'admin' as UserRole
};
} catch (error) {
console.error('Erro ao buscar papel do usuário:', error);
return {
accessLevel: 'Restrita' as AccessLevel,
isAdmin: false,
isGerencia: false,
isDiretoria: false,
role: 'user' as UserRole
};
}
},
enabled: !!user?.id,
});
// Extract the data and add loading/error handling
const { data, isLoading, error } = query;
return {
...data,
loading: isLoading,
error,
// Also include the query object for compatibility
...query
accessLevel: isAdminCheck ? 'Total' : 'Parcial' as AccessLevel,
isAdmin: isAdminCheck,
isGerencia: isAdminCheck, // fallback para componentes legados
isDiretoria: isAdminCheck, // fallback para componentes legados
role: (isAdminCheck ? 'admin' : 'user') as UserRole,
loading,
error: null,
};
};
// Helper function to check if user has role using correct AppRole type
// Helper function (placeholder, não usado)
export const hasRole = (userId: string | undefined, role: AppRole): boolean => {
// Implementation would check against supabase function
return false; // Placeholder
return false;
};
+41 -35
View File
@@ -41,8 +41,8 @@ export const useValidacaoSequencialProcessos = () => {
quantidade_produzida,
processo_id,
tipo_apontamento,
peca:pecas(marca),
processo:processos_fabricacao(ordem)
peca:pecas!apontamentos_producao_peca_id_fkey(marca),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(ordem)
`)
.eq('of_number', ofNumber)
.eq('tipo_apontamento', 'peca');
@@ -54,8 +54,8 @@ export const useValidacaoSequencialProcessos = () => {
quantidade_produzida,
processo_id,
tipo_apontamento,
componente:componentes_peca(marca_componente),
processo:processos_fabricacao(ordem)
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(ordem)
`)
.eq('of_number', ofNumber)
.eq('tipo_apontamento', 'componente');
@@ -163,15 +163,33 @@ export const useValidacaoSequencialProcessos = () => {
return { valido: true };
}, [historico, processos]);
// Função auxiliar para calcular quantidade disponível do processo anterior
const calcularQuantidadeDisponivelProcessoAnterior = useCallback(async (
marcaItem: string,
ordemProcessoAnterior: number,
ofNumber: string,
tipoItem: 'peca' | 'componente'
): Promise<number> => {
const processoAnterior = processos.find(p => p.ordem === ordemProcessoAnterior);
if (!processoAnterior) return 0;
return await buscarQuantidadeProcessada(
marcaItem,
processoAnterior.id,
ofNumber,
tipoItem
);
}, [processos, buscarQuantidadeProcessada]);
// Calcular itens disponíveis para um processo específico
const calcularItensDisponiveis = useCallback(async (
ofNumber: string,
processoId: string,
pecas: any[],
componentes: any[]
pecas: Record<string, unknown>[],
componentes: Record<string, unknown>[]
): Promise<{
pecasDisponiveis: any[];
componentesDisponiveis: any[];
pecasDisponiveis: Record<string, unknown>[];
componentesDisponiveis: Record<string, unknown>[];
}> => {
const processoAtual = processos.find(p => p.id === processoId);
if (!processoAtual) {
@@ -179,28 +197,31 @@ export const useValidacaoSequencialProcessos = () => {
}
const ordemAtual = processoAtual.ordem;
const pecasDisponiveis: any[] = [];
const componentesDisponiveis: any[] = [];
const pecasDisponiveis: Record<string, unknown>[] = [];
const componentesDisponiveis: Record<string, unknown>[] = [];
// Processar peças
for (const peca of pecas) {
const marcaPeca = String(peca.marca || '');
const qtdPeca = Number(peca.quantidade || 0);
if (ordemAtual === 1) {
// Primeiro processo: todas as peças estão disponíveis
pecasDisponiveis.push({
...peca,
quantidade_disponivel: peca.quantidade || 0
quantidade_disponivel: qtdPeca
});
} else {
// Calcular quantidade disponível baseada no processo anterior
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
peca.marca,
marcaPeca,
ordemAtual - 1,
ofNumber,
'peca'
);
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
peca.marca,
marcaPeca,
processoId,
ofNumber,
'peca'
@@ -219,23 +240,26 @@ export const useValidacaoSequencialProcessos = () => {
// Processar componentes
for (const componente of componentes) {
const marcaComp = String(componente.marca_componente || '');
const qtdComp = Number(componente.quantidade_total || 0);
if (ordemAtual === 1) {
// Primeiro processo: todos os componentes estão disponíveis
componentesDisponiveis.push({
...componente,
quantidade_disponivel: componente.quantidade_total || 0
quantidade_disponivel: qtdComp
});
} else {
// Calcular quantidade disponível baseada no processo anterior
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
componente.marca_componente,
marcaComp,
ordemAtual - 1,
ofNumber,
'componente'
);
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
componente.marca_componente,
marcaComp,
processoId,
ofNumber,
'componente'
@@ -253,25 +277,7 @@ export const useValidacaoSequencialProcessos = () => {
}
return { pecasDisponiveis, componentesDisponiveis };
}, [processos, buscarQuantidadeProcessada]);
// Função auxiliar para calcular quantidade disponível do processo anterior
const calcularQuantidadeDisponivelProcessoAnterior = async (
marcaItem: string,
ordemProcessoAnterior: number,
ofNumber: string,
tipoItem: 'peca' | 'componente'
): Promise<number> => {
const processoAnterior = processos.find(p => p.ordem === ordemProcessoAnterior);
if (!processoAnterior) return 0;
return await buscarQuantidadeProcessada(
marcaItem,
processoAnterior.id,
ofNumber,
tipoItem
);
};
}, [processos, buscarQuantidadeProcessada, calcularQuantidadeDisponivelProcessoAnterior]);
return {
historico,
+300
View File
@@ -0,0 +1,300 @@
// Client Logto puro (sem @logto/react, sem instalar pacotes)
// Usa PKCE (Logto exige pra apps SPA)
const LOGTO_ENDPOINT = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
const APP_ID = import.meta.env.VITE_LOGTO_APP_ID || '';
const REDIRECT_URI =
import.meta.env.VITE_LOGTO_REDIRECT_URI || window.location.origin + '/callback';
const POST_LOGOUT_REDIRECT_URI =
import.meta.env.VITE_LOGTO_POST_LOGOUT_REDIRECT_URI || window.location.origin;
const TOKEN_KEY = 'logto_token';
const ID_TOKEN_KEY = 'logto_id_token';
const REFRESH_KEY = 'logto_refresh';
const USER_KEY = 'logto_user';
export interface LogtoUser {
sub: string;
id?: string; // alias pra sub (compat com Supabase)
email?: string;
name?: string;
username?: string;
picture?: string;
}
export interface LogtoTokens {
accessToken: string;
idToken: string;
refreshToken?: string;
expiresAt: number;
}
// === Storage helpers ===
function saveTokens(t: LogtoTokens): void {
localStorage.setItem(TOKEN_KEY, t.accessToken);
localStorage.setItem(ID_TOKEN_KEY, t.idToken);
if (t.refreshToken) localStorage.setItem(REFRESH_KEY, t.refreshToken);
localStorage.setItem('logto_expires', String(t.expiresAt));
}
function loadTokens(): LogtoTokens | null {
const accessToken = localStorage.getItem(TOKEN_KEY);
const idToken = localStorage.getItem(ID_TOKEN_KEY);
const refreshToken = localStorage.getItem(REFRESH_KEY) || undefined;
const expiresAt = Number(localStorage.getItem('logto_expires') || 0);
if (!accessToken || !idToken) return null;
return { accessToken, idToken, refreshToken, expiresAt };
}
function clearTokens(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ID_TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem('logto_expires');
localStorage.removeItem(USER_KEY);
}
// === PKCE helpers ===
function generateRandomString(len: number): string {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const arr = new Uint8Array(len);
crypto.getRandomValues(arr);
let out = '';
for (let i = 0; i < len; i++) out += charset[arr[i] % charset.length];
return out;
}
async function sha256(input: string): Promise<ArrayBuffer> {
const data = new TextEncoder().encode(input);
return await crypto.subtle.digest('SHA-256', data);
}
function base64UrlEncode(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let str = '';
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
const b64 = btoa(str);
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function generatePkce(): Promise<{ verifier: string; challenge: string }> {
const verifier = generateRandomString(64);
const challenge = base64UrlEncode(await sha256(verifier));
return { verifier, challenge };
}
// === Auth flow ===
export async function signIn(): Promise<void> {
// Limpa estado anterior pra evitar PKCE mismatch
try {
sessionStorage.removeItem('logto_state');
sessionStorage.removeItem('logto_nonce');
sessionStorage.removeItem('logto_verifier');
} catch {
/* ignore */
}
const state = generateRandomString(32);
const nonce = generateRandomString(32);
const pkce = await generatePkce();
try {
sessionStorage.setItem('logto_state', state);
sessionStorage.setItem('logto_nonce', nonce);
sessionStorage.setItem('logto_verifier', pkce.verifier);
} catch {
/* ignore */
}
const params = new URLSearchParams({
client_id: APP_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid profile email',
state,
nonce,
code_challenge: pkce.challenge,
code_challenge_method: 'S256',
});
window.location.assign(LOGTO_ENDPOINT + '/oidc/auth?' + params.toString());
}
let callbackPromise: Promise<boolean> | null = null;
export async function handleCallback(): Promise<boolean> {
if (callbackPromise) return callbackPromise;
callbackPromise = (async () => {
try {
const url = new URL(window.location.href);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
console.log('[Logto Callback] Recebido:', { hasCode: !!code, hasState: !!state });
if (!code) return false;
const expectedState = sessionStorage.getItem('logto_state');
const verifier = sessionStorage.getItem('logto_verifier') || '';
console.log('[Logto Callback] State match?', { received: state, expected: expectedState, hasVerifier: !!verifier });
if (state !== expectedState) {
console.error('Logto: state mismatch');
return false;
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: APP_ID,
code,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
const res = await fetch(LOGTO_ENDPOINT + '/oidc/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const errorText = await res.text();
console.error('Token exchange failed:', errorText);
// Limpa código da URL pra evitar retry com code inválido
try {
sessionStorage.removeItem('logto_state');
sessionStorage.removeItem('logto_nonce');
sessionStorage.removeItem('logto_verifier');
} catch {
/* ignore */
}
return false;
}
const data = await res.json();
saveTokens({
accessToken: data.access_token,
idToken: data.id_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + (data.expires_in || 3600) * 1000,
});
// Limpa params URL
const cleanUrl = window.location.origin + window.location.pathname;
window.history.replaceState({}, document.title, cleanUrl);
return true;
} catch (err) {
console.error('Callback error:', err);
return false;
} finally {
// Limpa a promise depois de um tempo pra permitir novos logins futuros,
// mas bloqueia execuções duplas imediatas (React Strict Mode / uso duplo)
setTimeout(() => {
callbackPromise = null;
}, 2000);
}
})();
return callbackPromise;
}
export async function getUser(): Promise<LogtoUser | null> {
const tokens = loadTokens();
if (!tokens) return null;
const cached = localStorage.getItem(USER_KEY);
if (cached) {
try {
return JSON.parse(cached) as LogtoUser;
} catch {
/* ignore */
}
}
try {
const res = await fetch(LOGTO_ENDPOINT + '/oidc/me', {
headers: { Authorization: 'Bearer ' + tokens.accessToken },
});
if (!res.ok) {
if (res.status === 401) {
const refreshed = await refreshAccessToken();
if (refreshed) return getUser();
}
return null;
}
const user = (await res.json()) as LogtoUser;
const result: LogtoUser = { ...user, id: user.sub };
localStorage.setItem(USER_KEY, JSON.stringify(result));
return result;
} catch {
return null;
}
}
async function refreshAccessToken(): Promise<boolean> {
const tokens = loadTokens();
if (!tokens?.refreshToken) return false;
try {
const body = new URLSearchParams({
grant_type: 'refresh_token',
client_id: APP_ID,
refresh_token: tokens.refreshToken,
});
const res = await fetch(LOGTO_ENDPOINT + '/oidc/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) return false;
const data = await res.json();
saveTokens({
accessToken: data.access_token,
idToken: data.id_token,
refreshToken: data.refresh_token || tokens.refreshToken,
expiresAt: Date.now() + (data.expires_in || 3600) * 1000,
});
return true;
} catch {
return false;
}
}
export async function signOut(): Promise<void> {
clearTokens();
try {
sessionStorage.removeItem('logto_state');
sessionStorage.removeItem('logto_nonce');
sessionStorage.removeItem('logto_verifier');
} catch {
/* ignore */
}
window.location.assign(
LOGTO_ENDPOINT +
'/oidc/session/end?client_id=' +
encodeURIComponent(APP_ID) +
'&post_logout_redirect_uri=' +
encodeURIComponent(POST_LOGOUT_REDIRECT_URI)
);
}
export async function requestPasswordReset(_email: string): Promise<{ ok: boolean; error?: string }> {
// Logto: usuário clica "Esqueci senha" na tela de login e informa email
// Logto envia email com link de reset via SMTP já configurado
return { ok: true };
}
export function isAuthenticated(): boolean {
return loadTokens() !== null;
}
export function getAccessToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
+24 -320
View File
@@ -16,57 +16,35 @@ import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
const Auth = () => {
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showForgotPassword, setShowForgotPassword] = useState(false);
const [loginData, setLoginData] = useState({ email: '', password: '' });
const [signupData, setSignupData] = useState({ email: '', password: '', confirmPassword: '' });
const { signIn, signUp, user, loading, isRecoveryFlow, session } = useAuth();
const { signIn, user, loading, isRecoveryFlow, session } = useAuth();
const { brandSettings } = useBrandSettings();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const location = useLocation();
// Estado para controlar se está em modo de recuperação
const [showPasswordReset, setShowPasswordReset] = useState(false);
useEffect(() => {
console.log('🔍 Auth.tsx - Verificando fluxo de recuperação...');
console.log('Location:', {
pathname: location.pathname,
search: location.search,
hash: location.hash
});
console.log('Auth states:', {
user: !!user,
session: !!session,
isRecoveryFlow,
loading
});
// Verificar se é fluxo de recuperação
const recoveryType = searchParams.get('type');
const hashParams = new URLSearchParams(location.hash.substring(1));
const hashType = hashParams.get('type');
const accessToken = hashParams.get('access_token');
const refreshToken = hashParams.get('refresh_token');
console.log('🔍 Parâmetros de recuperação:', {
recoveryType,
hashType,
accessToken: !!accessToken,
refreshToken: !!refreshToken,
isRecoveryFlow
});
// Se há type=recovery OU isRecoveryFlow OU tokens, mostrar tela de reset
const shouldShowReset = recoveryType === 'recovery' ||
const shouldShowReset = Boolean(
recoveryType === 'recovery' ||
hashType === 'recovery' ||
isRecoveryFlow ||
(accessToken && refreshToken);
(accessToken && refreshToken)
);
console.log('🔑 Deve mostrar reset de senha:', shouldShowReset);
setShowPasswordReset(Boolean(shouldShowReset));
}, [searchParams, location.hash, location.search, isRecoveryFlow, user, session]);
// Estado para controlar se está em modo de recuperação
const [showPasswordReset, setShowPasswordReset] = useState(shouldShowReset);
useEffect(() => {
if (shouldShowReset !== showPasswordReset) {
// eslint-disable-next-line react-hooks/exhaustive-deps
setShowPasswordReset(shouldShowReset);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldShowReset, showPasswordReset, loading, location.pathname]);
// Redirecionar usuários autenticados para página principal (exceto em fluxo de recuperação)
useEffect(() => {
@@ -139,112 +117,18 @@ const Auth = () => {
);
}
const validatePassword = (password: string) => {
const minLength = password.length >= 8;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const score = [minLength, hasUpperCase, hasLowerCase, hasNumbers, hasSpecialChar].filter(Boolean).length;
return {
score,
minLength,
hasUpperCase,
hasLowerCase,
hasNumbers,
hasSpecialChar,
isValid: score >= 4 && minLength
};
};
const passwordStrength = validatePassword(signupData.password);
// Email validation
const isValidEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!loginData.email || !loginData.password) {
toast.error('Por favor, preencha todos os campos');
return;
}
if (!isValidEmail(loginData.email)) {
toast.error('Por favor, insira um email válido');
return;
}
const handleLogin = async () => {
setIsLoading(true);
const { error } = await signIn(loginData.email, loginData.password);
const { error } = await signIn();
if (error) {
if (error.message.includes('Invalid login credentials')) {
toast.error('Email ou senha incorretos');
} else {
toast.error('Erro ao fazer login: ' + error.message);
}
} else {
toast.success('Login realizado com sucesso!');
navigate('/');
}
toast.error('Erro ao redirecionar para o login: ' + (error as Error).message);
setIsLoading(false);
}
// No else block needed as it redirects to external OIDC provider
};
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
if (!signupData.email || !signupData.password || !signupData.confirmPassword) {
toast.error('Por favor, preencha todos os campos');
return;
}
if (!isValidEmail(signupData.email)) {
toast.error('Por favor, insira um email válido');
return;
}
if (!passwordStrength.isValid) {
toast.error('A senha deve ter pelo menos 8 caracteres e incluir maiúsculas, minúsculas, números e símbolos');
return;
}
if (signupData.password !== signupData.confirmPassword) {
toast.error('As senhas não coincidem');
return;
}
setIsLoading(true);
const { error } = await signUp(signupData.email, signupData.password);
if (error) {
if (error.message.includes('User already registered')) {
toast.error('Este email já está cadastrado');
} else {
toast.error('Erro ao criar conta: ' + error.message);
}
} else {
toast.success('Conta criada com sucesso! Verifique seu email.');
}
setIsLoading(false);
};
const getPasswordStrengthColor = (score: number) => {
if (score <= 2) return 'bg-red-500';
if (score <= 3) return 'bg-yellow-500';
return 'bg-green-500';
};
const getPasswordStrengthText = (score: number) => {
if (score <= 2) return 'Fraca';
if (score <= 3) return 'Média';
return 'Forte';
};
console.log('📱 Renderizando página de login/cadastro');
return (
@@ -281,188 +165,14 @@ const Auth = () => {
</div>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="login" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="login" className="text-sm">Entrar</TabsTrigger>
<TabsTrigger value="signup" className="text-sm">Cadastrar</TabsTrigger>
</TabsList>
<TabsContent value="login" className="space-y-4">
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="login-email" className="text-sm font-medium">
Email
</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="login-email"
type="email"
placeholder="seu@email.com"
value={loginData.email}
onChange={(e) => setLoginData({ ...loginData, email: e.target.value })}
className="pl-10 h-12"
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="login-password" className="text-sm font-medium">
Senha
</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="login-password"
type={showPassword ? "text" : "password"}
placeholder="Sua senha"
value={loginData.password}
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
className="pl-10 pr-10 h-12"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<CardContent className="pt-6">
<Button
type="submit"
className="w-full h-12 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium text-sm"
onClick={handleLogin}
className="w-full h-14 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium text-lg rounded-xl transition-all hover:scale-[1.02] shadow-md"
disabled={isLoading}
>
{isLoading ? 'Entrando...' : 'Entrar'}
{isLoading ? 'Redirecionando...' : 'Acessar Sistema Seguramente'}
</Button>
{/* Botão Esqueci a Senha */}
<div className="text-center">
<button
type="button"
onClick={() => setShowForgotPassword(true)}
className="text-sm text-muted-foreground hover:text-foreground underline"
disabled={isLoading}
>
Esqueci a senha
</button>
</div>
</form>
</TabsContent>
<TabsContent value="signup" className="space-y-4">
<form onSubmit={handleSignup} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signup-email" className="text-sm font-medium">
Email
</Label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="signup-email"
type="email"
placeholder="seu@email.com"
value={signupData.email}
onChange={(e) => setSignupData({ ...signupData, email: e.target.value })}
className="pl-10 h-12"
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="signup-password" className="text-sm font-medium">
Senha
</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="signup-password"
type={showPassword ? "text" : "password"}
placeholder="Mínimo 8 caracteres"
value={signupData.password}
onChange={(e) => setSignupData({ ...signupData, password: e.target.value })}
className="pl-10 pr-10 h-12"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{/* Password Strength Indicator */}
{signupData.password && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${getPasswordStrengthColor(passwordStrength.score)}`}
style={{ width: `${(passwordStrength.score / 5) * 100}%` }}
></div>
</div>
<span className="text-xs text-muted-foreground">
{getPasswordStrengthText(passwordStrength.score)}
</span>
</div>
<div className="text-xs space-y-1">
<div className={`flex items-center gap-1 ${passwordStrength.minLength ? 'text-green-600' : 'text-red-600'}`}>
<span className="w-2 h-2 rounded-full bg-current"></span>
Mínimo 8 caracteres
</div>
<div className={`flex items-center gap-1 ${passwordStrength.hasUpperCase ? 'text-green-600' : 'text-red-600'}`}>
<span className="w-2 h-2 rounded-full bg-current"></span>
Letra maiúscula
</div>
<div className={`flex items-center gap-1 ${passwordStrength.hasNumbers ? 'text-green-600' : 'text-red-600'}`}>
<span className="w-2 h-2 rounded-full bg-current"></span>
Número
</div>
<div className={`flex items-center gap-1 ${passwordStrength.hasSpecialChar ? 'text-green-600' : 'text-red-600'}`}>
<span className="w-2 h-2 rounded-full bg-current"></span>
Símbolo especial
</div>
</div>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="signup-confirm" className="text-sm font-medium">
Confirmar Senha
</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="signup-confirm"
type={showPassword ? "text" : "password"}
placeholder="Confirme sua senha"
value={signupData.confirmPassword}
onChange={(e) => setSignupData({ ...signupData, confirmPassword: e.target.value })}
className="pl-10 h-12"
disabled={isLoading}
/>
</div>
</div>
<Button
type="submit"
className="w-full h-12 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium text-sm"
disabled={isLoading}
>
{isLoading ? 'Criando conta...' : 'Criar conta'}
</Button>
</form>
</TabsContent>
</Tabs>
</CardContent>
</Card>
@@ -471,12 +181,6 @@ const Auth = () => {
</p>
</div>
</div>
{/* Modal Esqueci a Senha */}
<ForgotPasswordModal
isOpen={showForgotPassword}
onClose={() => setShowForgotPassword(false)}
/>
</BeamsBackground>
);
};
+1 -1
View File
@@ -488,7 +488,7 @@ const CadastroOF = () => {
/>
</div>
<div>
<Label htmlFor="quantidade" className="text-sm font-medium text-muted-foreground">Quantidade (t)</Label>
<Label htmlFor="quantidade" className="text-sm font-medium text-muted-foreground">Quantidade (kg)</Label>
<Input
id="quantidade"
type="number"
+1
View File
@@ -240,6 +240,7 @@ export default function CadastroPecasFiltrado() {
<CardContent>
<PecaForm
ofNumbers={[ofSelecionada]} // Apenas a OF selecionada
ofDefault={ofSelecionada}
onSave={handleSave}
onUpdate={handleUpdate}
onImportCSV={handleImportCSV}
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { BeamsBackground } from '@/components/ui/beams-background';
import { useAuth } from '@/hooks/useAuth';
const Callback = () => {
const navigate = useNavigate();
const { handleCallback, user, loading } = useAuth();
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing');
const [message, setMessage] = useState('Processando login...');
useEffect(() => {
let cancelled = false;
async function processCallback() {
try {
const ok = await handleCallback();
if (cancelled) return;
if (ok) {
setStatus('success');
setMessage('Login realizado com sucesso!');
setTimeout(() => navigate('/'), 1000);
} else {
setStatus('error');
setMessage('Falha no login. Tente novamente.');
// Não navega automaticamente - deixa o user ver o erro
}
} catch (err) {
if (cancelled) return;
setStatus('error');
setMessage('Erro inesperado: ' + (err as Error).message);
setTimeout(() => navigate('/auth'), 3000);
}
}
processCallback();
return () => {
cancelled = true;
};
}, [handleCallback, navigate]);
// Se já tá logado, redireciona
useEffect(() => {
if (!loading && user) {
navigate('/');
}
}, [user, loading, navigate]);
return (
<BeamsBackground intensity="medium">
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center bg-background/80 backdrop-blur-sm rounded-2xl shadow-xl p-8 max-w-md">
{status === 'processing' && (
<>
<div className="h-12 w-12 mx-auto mb-4 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-lg font-medium text-foreground">{message}</p>
</>
)}
{status === 'success' && (
<>
<div className="mx-auto mb-4 w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<svg
className="w-6 h-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<p className="text-lg font-medium text-foreground">{message}</p>
</>
)}
{status === 'error' && (
<>
<div className="mx-auto mb-4 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg
className="w-6 h-6 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<p className="text-lg font-medium text-foreground">{message}</p>
</>
)}
</div>
</div>
</BeamsBackground>
);
};
export default Callback;
+7 -16
View File
@@ -174,22 +174,7 @@ const ConversoresDados = () => {
);
}
if (showAdvanceSteelConverter) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={handleGoBack}
className="bg-slate-600 border-slate-500 text-white hover:bg-slate-500"
>
Voltar
</Button>
</div>
<AdvanceSteelConverter />
</div>
);
}
return (
<div className="space-y-6">
@@ -373,6 +358,12 @@ const ConversoresDados = () => {
open={showGenericConverter}
onOpenChange={setShowGenericConverter}
/>
{/* Modal de Conversão Advance Steel (PDF para Excel) */}
<AdvanceSteelConverter
open={showAdvanceSteelConverter}
onOpenChange={setShowAdvanceSteelConverter}
/>
</div>
);
};
+11 -5
View File
@@ -14,7 +14,7 @@ import { CronogramaGantt } from '@/components/cronograma/CronogramaGantt';
import { CronogramaPDF } from '@/components/cronograma/CronogramaPDF';
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
import { usePermissionControl } from '@/hooks/usePermissionControl';
import { CronogramaOf } from '@/types/cronograma';
import { CronogramaOf, ProcessoCronograma } from '@/types/cronograma';
const CronogramaOF = () => {
const { cronogramas, loading, loadCronogramas, deleteCronograma } = useCronogramas();
@@ -24,7 +24,7 @@ const CronogramaOF = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedOF, setSelectedOF] = useState<string>('');
const [showCronogramaForm, setShowCronogramaForm] = useState(false);
const [selectedCronograma, setSelectedCronograma] = useState<any>(null);
const [selectedCronograma, setSelectedCronograma] = useState<CronogramaOf | null>(null);
const [showGanttChart, setShowGanttChart] = useState(false);
const [showPDFGenerator, setShowPDFGenerator] = useState(false);
const [cronogramaForGantt, setCronogramaForGantt] = useState<CronogramaOf | null>(null);
@@ -42,7 +42,7 @@ const CronogramaOF = () => {
return ofA.localeCompare(ofB);
});
const handleEdit = (cronograma: any) => {
const handleEdit = (cronograma: CronogramaOf) => {
if (!canEdit()) return;
setSelectedCronograma(cronograma);
setShowCronogramaForm(true);
@@ -68,6 +68,11 @@ const CronogramaOF = () => {
setSelectedCronograma(null);
};
const handleSaveSuccess = () => {
loadCronogramas();
setActiveTab('cronogramas');
};
const handleCloseGanttChart = () => {
setShowGanttChart(false);
setCronogramaForGantt(null);
@@ -78,7 +83,7 @@ const CronogramaOF = () => {
setCronogramaForPDF(null);
};
const MobileCronogramaCard = ({ cronograma }: { cronograma: any }) => (
const MobileCronogramaCard = ({ cronograma }: { cronograma: CronogramaOf }) => (
<Card className="w-full mb-4 bg-card border-border">
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
@@ -120,7 +125,7 @@ const CronogramaOF = () => {
<div className="mt-1 text-card-foreground">
{Array.isArray(cronograma.processos) && cronograma.processos.length > 0 ? (
<div className="text-xs">
{cronograma.processos.map((processo: any, index: number) => (
{cronograma.processos.map((processo: ProcessoCronograma, index: number) => (
<div key={index} className="py-1 border-b border-border last:border-0">
{processo.nome_processo || `Processo ${index + 1}`}
</div>
@@ -285,6 +290,7 @@ const CronogramaOF = () => {
cronograma={selectedCronograma}
onClose={handleCloseCronogramaForm}
isOpen={showCronogramaForm}
onSaveSuccess={handleSaveSuccess}
/>
)}
+94 -82
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
@@ -18,82 +18,7 @@ import { useUserRole } from '@/hooks/useUserRole';
import { UserResourcePermissions } from '@/components/mapa-interativo/UserResourcePermissions';
import { cn } from '@/lib/utils';
export default function MapaInterativo() {
const { nodes, connections } = useSystemMapAutoDiscovery();
const { isAdmin } = useUserRole();
const [activeFilter, setActiveFilter] = useState<string>('all');
const [selectedNode, setSelectedNode] = useState<SystemMapNode | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [draggedNode, setDraggedNode] = useState<string | null>(null);
const [highlightedPath, setHighlightedPath] = useState<string[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<{ title: string; description: string; group: string }>({ title: '', description: '', group: '' });
const mapContainerRef = useRef<HTMLDivElement>(null);
const filters = [
{ id: 'all', label: 'Ver Tudo', icon: Eye },
{ id: 'main-flow', label: 'Fluxo Principal', icon: Workflow },
{ id: 'permissions', label: 'Meus Acessos', icon: Shield }
];
const filteredNodes = nodes.filter(node => {
switch (activeFilter) {
case 'main-flow': return node.isMainFlow;
case 'permissions': return node.hasAccess;
default: return true;
}
});
const filteredConnections = connections.filter(conn => {
const sourceExists = filteredNodes.some(n => n.id === conn.from);
const targetExists = filteredNodes.some(n => n.id === conn.to);
return sourceExists && targetExists;
});
const getMapDimensions = () => {
if (filteredNodes.length === 0) return { width: 1200, height: 800 };
const cardWidth = 240;
const cardHeight = 140;
const padding = 100; // Padding extra para garantir espaço
let maxX = 0;
let maxY = 0;
filteredNodes.forEach(node => {
const nodeEl = mapContainerRef.current?.querySelector(`[data-node="${node.id}"]`) as HTMLElement;
if (nodeEl) {
const rect = nodeEl.getBoundingClientRect();
const containerRect = mapContainerRef.current!.getBoundingClientRect();
maxX = Math.max(maxX, rect.left - containerRect.left + cardWidth);
maxY = Math.max(maxY, rect.top - containerRect.top + cardHeight);
} else {
// Usar posições iniciais se o elemento ainda não foi renderizado
maxX = Math.max(maxX, node.left + cardWidth);
maxY = Math.max(maxY, node.top + cardHeight);
}
});
return {
width: Math.max(1200, maxX + padding),
height: Math.max(800, maxY + padding)
};
};
const [mapDimensions, setMapDimensions] = useState(getMapDimensions());
useEffect(() => {
const updateDimensions = () => {
setMapDimensions(getMapDimensions());
};
updateDimensions();
const interval = setInterval(updateDimensions, 1000); // Atualizar periodicamente
return () => clearInterval(interval);
}, [filteredNodes]);
const createCurvedPath = (fromX: number, fromY: number, toX: number, toY: number) => {
const createCurvedPath = (fromX: number, fromY: number, toX: number, toY: number) => {
const midX = (fromX + toX) / 2;
const midY = (fromY + toY) / 2;
@@ -109,9 +34,9 @@ export default function MapaInterativo() {
const controlY = midY + offsetY;
return `M ${fromX} ${fromY} Q ${controlX} ${controlY} ${toX} ${toY}`;
};
};
const getArrowPositions = (fromX: number, fromY: number, toX: number, toY: number) => {
const getArrowPositions = (fromX: number, fromY: number, toX: number, toY: number) => {
const arrows = [];
const steps = 3; // Número de setas ao longo da linha
@@ -140,9 +65,96 @@ export default function MapaInterativo() {
}
return arrows;
};
export default function MapaInterativo() {
const { nodes, connections } = useSystemMapAutoDiscovery();
const { isAdmin } = useUserRole();
const [activeFilter, setActiveFilter] = useState<string>('all');
const [selectedNode, setSelectedNode] = useState<SystemMapNode | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [draggedNode, setDraggedNode] = useState<string | null>(null);
const [highlightedPath, setHighlightedPath] = useState<string[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<{ title: string; description: string; group: string }>({ title: '', description: '', group: '' });
const mapContainerRef = useRef<HTMLDivElement>(null);
const filters = [
{ id: 'all', label: 'Ver Tudo', icon: Eye },
{ id: 'main-flow', label: 'Fluxo Principal', icon: Workflow },
{ id: 'permissions', label: 'Meus Acessos', icon: Shield }
];
const filteredNodes = useMemo(() => {
return nodes.filter(node => {
switch (activeFilter) {
case 'main-flow': return node.isMainFlow;
case 'permissions': return node.hasAccess;
default: return true;
}
});
}, [nodes, activeFilter]);
const filteredConnections = useMemo(() => {
return connections.filter(conn => {
const sourceExists = filteredNodes.some(n => n.id === conn.from);
const targetExists = filteredNodes.some(n => n.id === conn.to);
return sourceExists && targetExists;
});
}, [connections, filteredNodes]);
const getMapDimensions = useCallback(() => {
if (filteredNodes.length === 0) return { width: 1200, height: 800 };
const cardWidth = 240;
const cardHeight = 140;
const padding = 100; // Padding extra para garantir espaço
let maxX = 0;
let maxY = 0;
filteredNodes.forEach(node => {
const nodeEl = mapContainerRef.current?.querySelector(`[data-node="${node.id}"]`) as HTMLElement;
if (nodeEl) {
const rect = nodeEl.getBoundingClientRect();
const containerRect = mapContainerRef.current!.getBoundingClientRect();
maxX = Math.max(maxX, rect.left - containerRect.left + cardWidth);
maxY = Math.max(maxY, rect.top - containerRect.top + cardHeight);
} else {
// Usar posições iniciais se o elemento ainda não foi renderizado
maxX = Math.max(maxX, node.left + cardWidth);
maxY = Math.max(maxY, node.top + cardHeight);
}
});
return {
width: Math.max(1200, maxX + padding),
height: Math.max(800, maxY + padding)
};
}, [filteredNodes]);
const [mapDimensions, setMapDimensions] = useState({ width: 1200, height: 800 });
useEffect(() => {
const updateDimensions = () => {
setMapDimensions(prev => {
const next = getMapDimensions();
if (prev.width === next.width && prev.height === next.height) {
return prev;
}
return next;
});
};
const updateConnections = () => {
updateDimensions();
const interval = setInterval(updateDimensions, 1000); // Atualizar periodicamente
return () => clearInterval(interval);
}, [getMapDimensions]);
const updateConnections = useCallback(() => {
if (!mapContainerRef.current) return;
const container = mapContainerRef.current;
@@ -179,14 +191,14 @@ export default function MapaInterativo() {
}
}
});
};
}, [filteredConnections]);
useEffect(() => {
updateConnections();
const handleResize = () => updateConnections();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [filteredNodes, filteredConnections]);
}, [updateConnections]);
const handleNodeMouseOver = (nodeId: string) => {
const relatedNodes = new Set<string>();
+112 -235
View File
@@ -24,7 +24,8 @@ const PrioridadesFabricacao = () => {
ofSelecionada,
faseSelecionada,
versaoAtual,
onFiltroChange
onFiltroChange,
incrementarRevisao
} = useItensPrioridadeFabricacaoFiltrado();
const [showPecaSelector, setShowPecaSelector] = useState(false);
@@ -58,17 +59,30 @@ const PrioridadesFabricacao = () => {
const numBigBoxes = Math.floor(quantity / 5);
const numSmallBoxes = quantity % 5;
for (let i = 0; i < numBigBoxes; i++) {
boxesHtml += `<div class="tick-box-large"><span>5</span></div>`;
boxesHtml += `
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -1px; margin-right: 2px;">
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#f3f4f6" stroke="#4b5563" stroke-width="1" />
<text x="6.5" y="9.5" text-anchor="middle" font-size="8.5" font-family="Arial, sans-serif" font-weight="bold" fill="#4b5563">5</text>
</svg>
`;
}
for (let i = 0; i < numSmallBoxes; i++) {
boxesHtml += `<div class="tick-box"></div>`;
boxesHtml += `
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -1px; margin-right: 2px;">
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1" />
</svg>
`;
}
} else {
for (let i = 0; i < quantity; i++) {
boxesHtml += `<div class="tick-box"></div>`;
boxesHtml += `
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -1px; margin-right: 2px;">
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1" />
</svg>
`;
}
}
return `<div class="flex items-center flex-wrap gap-1">${boxesHtml}</div>`;
return `<span style="display: inline-block; vertical-align: middle; margin-left: 4px;">${boxesHtml}</span>`;
};
const handleImprimirRelatorio = async () => {
@@ -77,6 +91,14 @@ const PrioridadesFabricacao = () => {
return;
}
let revisaoParaImprimir = versaoAtual?.revisao || 0;
if (window.confirm(`Deseja gerar uma nova revisão (Rev. ${(versaoAtual?.revisao || 0) + 1}) para esta impressão?`)) {
const sucesso = await incrementarRevisao();
if (sucesso) {
revisaoParaImprimir += 1;
}
}
try {
// Criar nova janela para impressão
const printWindow = window.open('', '_blank');
@@ -106,11 +128,11 @@ const PrioridadesFabricacao = () => {
const getCoresPrioridade = (codigo: string) => {
switch (codigo) {
case 'P1': return 'text-red-700 bg-red-100';
case 'P2': return 'text-orange-700 bg-orange-100';
case 'P3': return 'text-blue-700 bg-blue-100';
case 'P4': return 'text-gray-700 bg-gray-200';
default: return 'text-gray-700 bg-gray-200';
case 'P1': return 'text-red-700 bg-red-100 border-red-300';
case 'P2': return 'text-orange-700 bg-orange-100 border-orange-300';
case 'P3': return 'text-blue-700 bg-blue-100 border-blue-300';
case 'P4': return 'text-gray-700 bg-gray-200 border-gray-300';
default: return 'text-gray-700 bg-gray-200 border-gray-300';
}
};
@@ -119,15 +141,15 @@ const PrioridadesFabricacao = () => {
itemsContent += `
<div class="priority-group ${pageBreakClass}">
<h2 class="text-lg font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-3">
<h2 class="text-base font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-2.5 border">
${getPrioridadeNome(codigo)}
</h2>
<div class="space-y-1">
<div class="space-y-1.5">
`;
// Gerar linhas de itens (3 por linha)
for (let i = 0; i < Math.ceil(itens.length / 3); i++) {
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50' : 'bg-white';
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50/70' : 'bg-white';
const rowItems = itens.slice(i * 3, (i + 1) * 3);
itemsContent += `<div class="grid grid-cols-3 gap-2 p-1 rounded-md ${bgColorClass}">`;
@@ -141,13 +163,13 @@ const PrioridadesFabricacao = () => {
itemsContent += `
<div class="item-card">
<div class="flex items-center flex-wrap gap-2 mb-2">
<span class="font-semibold text-sm whitespace-nowrap">${marca} (${quantidade})</span>
<span class="text-xs font-medium text-gray-500">${infoType}</span>
<div style="margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<span style="font-size: 13px; font-weight: 700; color: #111827; vertical-align: middle; margin-right: 4px;">${marca} (${quantidade})</span>
<span style="font-size: 11px; font-weight: 600; color: #6b7280; vertical-align: middle;">${infoType}</span>
${tickBoxes}
</div>
<div class="mt-2 text-xs">
<div class="border-b border-gray-400 pb-1 h-5">Data/Operador:</div>
<div class="item-signature">
Data/Operador:
</div>
</div>
`;
@@ -176,20 +198,33 @@ const PrioridadesFabricacao = () => {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checklist de Produção por Prioridade</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.item-card {
border: 1px solid #e5e7eb;
padding: 8px 10px;
border-radius: 6px;
background-color: #ffffff;
box-sizing: border-box;
}
.item-signature {
margin-top: 6px;
font-size: 11px;
color: #4b5563;
border-bottom: 1px solid #9ca3af;
padding-bottom: 2px;
height: 18px;
line-height: 14px;
}
@media print {
body {
font-size: 9px;
}
.check-box-print {
border: 1px solid #333 !important;
}
.page-break {
page-break-before: always;
}
@@ -200,85 +235,80 @@ const PrioridadesFabricacao = () => {
page-break-inside: avoid;
}
}
.item-card {
border: 1px solid #e5e7eb;
padding: 8px;
border-radius: 6px;
}
.tick-box {
width: 12px;
height: 12px;
border: 1px solid #6b7280;
display: inline-block;
flex-shrink: 0;
}
.tick-box-large {
width: 16px;
height: 16px;
border: 1px solid #6b7280;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
}
.tick-box-large span {
color: #d1d5db;
font-size: 10px;
font-weight: 600;
}
</style>
</head>
<body class="bg-white">
<div class="max-w-4xl mx-auto p-6 sm:p-8">
<!-- Cabeçalho do Relatório -->
<div class="flex justify-between items-center border-b-2 border-gray-800 pb-4 mb-4">
<div class="flex justify-between items-center border-b-2 border-gray-800 pb-3 mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Checklist de Produção</h1>
<p class="text-gray-600">Formulário para apontamento da fabricação.</p>
<h1 class="text-2xl font-bold text-gray-900 leading-tight">Checklist de Produção</h1>
<p class="text-xs text-gray-600">Formulário para apontamento da fabricação.</p>
</div>
<div class="text-right">
<p class="font-semibold">Data de Emissão: <span class="font-normal">${dataAtual}</span>
${versaoAtual ? `<span class="ml-2 text-gray-500">Rev. ${versaoAtual.revisao}</span>` : ''}
<p class="font-semibold text-sm">Data de Emissão: <span class="font-normal">${dataAtual}</span>
<span class="ml-2 text-gray-500 font-medium">Rev. ${revisaoParaImprimir}</span>
</p>
</div>
</div>
<!-- Informações da OF e Fase (Layout Melhorado) -->
<div class="border border-gray-200 bg-white p-4 rounded-lg mb-2">
<div class="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
<!-- Informações da OF e Fase -->
<div class="border border-gray-200 bg-white p-3.5 rounded-lg mb-3">
<div class="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-3">
<!-- Coluna OF -->
<div>
<p class="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
<p class="text-base font-bold text-gray-800">${ofSelecionada}</p>
<p class="text-base font-bold text-gray-800 leading-snug">${ofSelecionada}</p>
</div>
<!-- Coluna Fase -->
<div>
<p class="text-xs font-medium text-gray-500">Fase</p>
<p class="text-base font-bold text-gray-800">${faseSelecionada}</p>
<p class="text-base font-bold text-gray-800 leading-snug">${faseSelecionada}</p>
</div>
<!-- Coluna Processo -->
<div class="md:col-span-2">
<p class="text-xs font-medium text-gray-500">PROCESSO</p>
<div class="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1">
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Corte</span></div>
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Solda</span></div>
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Pintura</span></div>
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Expedição</span></div>
<p class="text-xs font-medium text-gray-500 mb-1">PROCESSO</p>
<div style="margin-top: 2px;">
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
</svg>
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Corte</span>
</div>
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
</svg>
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Solda</span>
</div>
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
</svg>
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Pintura</span>
</div>
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
</svg>
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Expedição</span>
</div>
</div>
</div>
</div>
</div>
<!-- Legenda -->
<div class="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3">
<span class="font-semibold">Legenda:</span>
<span>Marca (Qtd)</span>
<span class="font-medium text-gray-500">(S/M)</span>
<span>= Sem Montagem,</span>
<span class="font-medium text-gray-500">(C/M)</span>
<span>= Com Montagem. Os quadrados</span>
<div class="tick-box inline-block"></div>
<div class="text-xs text-gray-600 mb-5 flex items-center flex-wrap">
<span class="font-semibold" style="margin-right: 6px;">Legenda:</span>
<span style="margin-right: 6px;">Marca (Qtd)</span>
<span class="font-medium text-gray-500" style="margin-right: 4px;">(S/M)</span>
<span style="margin-right: 6px;">= Sem Montagem,</span>
<span class="font-medium text-gray-500" style="margin-right: 4px;">(C/M)</span>
<span style="margin-right: 4px;">= Com Montagem. Os quadrados</span>
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -2px; margin: 0 4px;">
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1" />
</svg>
<span>indicam o controle de peças fabricadas.</span>
</div>
@@ -369,7 +399,12 @@ const PrioridadesFabricacao = () => {
<Button
variant="outline"
size="sm"
onClick={() => setShowPrioridadesPDF(true)}
onClick={async () => {
if (window.confirm(`Deseja gerar uma nova revisão (Rev. ${(versaoAtual?.revisao || 0) + 1}) para este PDF?`)) {
await incrementarRevisao();
}
setShowPrioridadesPDF(true);
}}
disabled={!ofSelecionada || !faseSelecionada}
>
<FileText className="h-4 w-4 mr-2" />
@@ -420,168 +455,10 @@ const PrioridadesFabricacao = () => {
isOpen={showPrioridadesPDF}
onClose={() => setShowPrioridadesPDF(false)}
itensPorPrioridade={itensPorPrioridade}
ofSelecionada={ofSelecionada}
faseSelecionada={faseSelecionada}
versaoAtual={versaoAtual}
/>
{/* Componente oculto para impressão */}
<div className="hidden">
<div id="prioridades-pdf-content">
{(ofSelecionada && faseSelecionada) && (
<div className="bg-white text-black max-w-4xl mx-auto p-6">
{/* Usar o mesmo template do PDF */}
<div className="flex justify-between items-center border-b-2 border-gray-800 pb-4 mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Checklist de Produção</h1>
<p className="text-gray-600">Formulário para apontamento da fabricação.</p>
</div>
<div className="text-right">
<p className="font-semibold">
Data de Emissão: <span className="font-normal">{new Date().toLocaleDateString('pt-BR')}</span>
{versaoAtual && (
<span className="ml-2 text-gray-500">Rev. {versaoAtual.revisao}</span>
)}
</p>
</div>
</div>
<div className="border border-gray-200 bg-white p-4 rounded-lg mb-2">
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
<div>
<p className="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
<p className="text-base font-bold text-gray-800">{ofSelecionada}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500">Fase</p>
<p className="text-base font-bold text-gray-800">{faseSelecionada}</p>
</div>
<div className="md:col-span-2">
<p className="text-xs font-medium text-gray-500">PROCESSO</p>
<div className="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1">
{['Corte', 'Solda', 'Pintura', 'Expedição'].map((processo) => (
<div key={processo} className="flex items-center gap-1">
<div className="w-4 h-4 border-2 border-gray-500"></div>
<span className="text-sm font-semibold text-gray-700">{processo}</span>
</div>
))}
</div>
</div>
</div>
</div>
<div className="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3">
<span className="font-semibold">Legenda:</span>
<span>Marca (Qtd)</span>
<span className="font-medium text-gray-500">(S/M)</span>
<span>= Sem Montagem,</span>
<span className="font-medium text-gray-500">(C/M)</span>
<span>= Com Montagem. Os quadrados indicam o controle de peças fabricadas.</span>
</div>
{/* Renderizar os itens por prioridade */}
<div className="space-y-8">
{['P1', 'P2', 'P3', 'P4'].map((codigo, priorityIndex) => {
const itens = itensPorPrioridade[codigo] || [];
if (itens.length === 0) return null;
const getPrioridadeNome = (codigo: string) => {
switch (codigo) {
case 'P1': return 'Prioridade P1 - Urgente';
case 'P2': return 'Prioridade P2 - Alta';
case 'P3': return 'Prioridade P3 - Média';
case 'P4': return 'Prioridade P4 - Baixa';
default: return 'Desconhecida';
}
};
const getCoresPrioridade = (codigo: string) => {
switch (codigo) {
case 'P1': return 'text-red-700 bg-red-100';
case 'P2': return 'text-orange-700 bg-orange-100';
case 'P3': return 'text-blue-700 bg-blue-100';
case 'P4': return 'text-gray-700 bg-gray-200';
default: return 'text-gray-700 bg-gray-200';
}
};
return (
<div key={codigo}>
<h2 className={`text-lg font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-3`}>
{getPrioridadeNome(codigo)}
</h2>
<div className="space-y-1">
{Array.from({ length: Math.ceil(itens.length / 3) }, (_, i) => {
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50' : 'bg-white';
const rowItems = itens.slice(i * 3, (i + 1) * 3);
return (
<div key={i} className={`grid grid-cols-3 gap-2 p-1 rounded-md ${bgColorClass}`}>
{rowItems.map((item) => {
const quantidade = item.quantidade_priorizada;
const marca = item.peca?.marca || 'N/A';
const temComponentes = item.peca?.tem_componentes;
const infoType = temComponentes ? '(C/M)' : '(S/M)';
// Gerar checkboxes
const generateTickBoxes = (quantity: number) => {
const boxes = [];
if (quantity > 10) {
const numBigBoxes = Math.floor(quantity / 5);
const numSmallBoxes = quantity % 5;
for (let i = 0; i < numBigBoxes; i++) {
boxes.push(
<div key={`big-${i}`} className="tick-box-large">
<span>5</span>
</div>
);
}
for (let i = 0; i < numSmallBoxes; i++) {
boxes.push(<div key={`small-${i}`} className="tick-box"></div>);
}
} else {
for (let i = 0; i < quantity; i++) {
boxes.push(<div key={i} className="tick-box"></div>);
}
}
return <div className="flex items-center flex-wrap gap-1">{boxes}</div>;
};
return (
<div key={item.id} className="item-card">
<div className="flex items-center flex-wrap gap-2 mb-2">
<span className="font-semibold text-sm whitespace-nowrap">
{marca} ({quantidade})
</span>
<span className="text-xs font-medium text-gray-500">{infoType}</span>
{generateTickBoxes(quantidade)}
</div>
<div className="mt-2 text-xs">
<div className="border-b border-gray-400 pb-1 h-5">Data/Operador:</div>
</div>
</div>
);
})}
{/* Preencher células vazias se necessário */}
{Array.from({ length: 3 - rowItems.length }, (_, emptyIndex) => (
<div key={`empty-${emptyIndex}`}></div>
))}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
</StandardPageLayout>
);
+11 -11
View File
@@ -5,7 +5,7 @@ import { StandardCard } from '@/components/layout/StandardCard';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Plus, Printer, Edit, Trash2, Eye, RefreshCw, Check, ShoppingCart } from 'lucide-react';
import { useSolicitacoesCompra } from '@/hooks/useSolicitacoesCompra';
import { useSolicitacoesCompra, SolicitacaoCompra, ItemSolicitacao } from '@/hooks/useSolicitacoesCompra';
import { useUserFunction } from '@/hooks/useUserFunction';
import { SolicitacaoComprasModal } from '@/components/solicitacao-compras/SolicitacaoComprasModal';
import { SolicitacaoComprasPreviewModal } from '@/components/solicitacao-compras/SolicitacaoComprasPreviewModal';
@@ -25,7 +25,7 @@ const statusColors = {
export default function SolicitacaoCompras() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [selectedSolicitacao, setSelectedSolicitacao] = useState(null);
const [selectedSolicitacao, setSelectedSolicitacao] = useState<SolicitacaoCompra | null>(null);
const { isComprador } = useUserFunction();
const {
solicitacoes,
@@ -44,7 +44,7 @@ export default function SolicitacaoCompras() {
isComprandoDireto
} = useSolicitacoesCompra();
const handlePrint = (solicitacao: any) => {
const handlePrint = (solicitacao: SolicitacaoCompra) => {
try {
const printContent = `
<!DOCTYPE html>
@@ -129,7 +129,7 @@ export default function SolicitacaoCompras() {
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
${solicitacao.itens.map((item: any, index: number) => `
${solicitacao.itens.map((item: ItemSolicitacao, index: number) => `
<tr${index % 2 === 1 ? ' class="hover:bg-gray-50"' : ''}>
<td class="px-4 py-3 text-xs font-medium text-gray-800 break-words">${item.material?.descricao || 'Material não encontrado'}</td>
<td class="px-4 py-3 whitespace-nowrap text-center text-gray-600 text-xs">${item.material?.unidade || 'UN'}</td>
@@ -180,7 +180,7 @@ export default function SolicitacaoCompras() {
}
};
const handleEdit = (solicitacao: any) => {
const handleEdit = (solicitacao: SolicitacaoCompra) => {
if (!canEdit(solicitacao)) {
alert('Você só pode editar suas próprias solicitações.');
return;
@@ -189,12 +189,12 @@ export default function SolicitacaoCompras() {
setIsModalOpen(true);
};
const handlePreview = (solicitacao: any) => {
const handlePreview = (solicitacao: SolicitacaoCompra) => {
setSelectedSolicitacao(solicitacao);
setIsPreviewOpen(true);
};
const handleDelete = (solicitacao: any) => {
const handleDelete = (solicitacao: SolicitacaoCompra) => {
if (!canDelete(solicitacao)) {
if (solicitacao.status !== 'Em planejamento') {
alert('⚠️ PERMISSÃO NEGADA\n\nSolicitações com status diferente de "Em planejamento" só podem ser excluídas por compradores.');
@@ -209,25 +209,25 @@ export default function SolicitacaoCompras() {
}
};
const handleRevisar = (solicitacao: any) => {
const handleRevisar = (solicitacao: SolicitacaoCompra) => {
if (window.confirm(`Tem certeza que deseja enviar a solicitação ${solicitacao.numero_sc} para revisão?`)) {
revisar({ id: solicitacao.id, created_by: solicitacao.created_by });
}
};
const handleAceitar = (solicitacao: any) => {
const handleAceitar = (solicitacao: SolicitacaoCompra) => {
if (window.confirm(`Tem certeza que deseja aceitar a solicitação ${solicitacao.numero_sc}?`)) {
aceitar({ id: solicitacao.id, created_by: solicitacao.created_by });
}
};
const handleComprar = (solicitacao: any) => {
const handleComprar = (solicitacao: SolicitacaoCompra) => {
if (window.confirm(`Tem certeza que deseja marcar a solicitação ${solicitacao.numero_sc} como comprada?`)) {
comprar({ id: solicitacao.id, created_by: solicitacao.created_by });
}
};
const handleComprarDireto = (solicitacao: any) => {
const handleComprarDireto = (solicitacao: SolicitacaoCompra) => {
if (window.confirm(`Você deseja passar o status dessa SC ${solicitacao.numero_sc} para "Comprado"?`)) {
comprarDireto({ id: solicitacao.id, created_by: solicitacao.created_by });
}
+5 -5
View File
@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge';
import { Search, Eye, Download, FileText, AlertTriangle, CheckCircle } from 'lucide-react';
import { useOFsAtivas } from '@/hooks/useOFsAtivas';
import { useAuditoriaInconsistencias } from '@/hooks/useAuditoriaInconsistencias';
import { useAuditoriaInconsistencias, InconsistenciaItem } from '@/hooks/useAuditoriaInconsistencias';
import { InconsistenciaDetalhesModal } from '@/components/auditoria/InconsistenciaDetalhesModal';
import { generateProfessionalPDF } from '@/utils/pdfGenerator';
import { toast } from '@/hooks/use-toast';
@@ -18,7 +18,7 @@ const VerInconsistencias = () => {
const [selectedFase, setSelectedFase] = useState<string>('');
const [selectedProcesso, setSelectedProcesso] = useState<string>('todas');
const [fasesDaOF, setFasesDaOF] = useState<string[]>([]);
const [detalhesModal, setDetalhesModal] = useState<{ open: boolean; inconsistencia: any }>({
const [detalhesModal, setDetalhesModal] = useState<{ open: boolean; inconsistencia: InconsistenciaItem | null }>({
open: false,
inconsistencia: null
});
@@ -113,7 +113,7 @@ const VerInconsistencias = () => {
const getTotalInconsistencias = () => {
if (!resultadoAuditoria) return 0;
return Object.values(resultadoAuditoria.inconsistencias).reduce((total, categoria: any) => total + categoria.length, 0);
return Object.values(resultadoAuditoria.inconsistencias).reduce((total, categoria: InconsistenciaItem[]) => total + categoria.length, 0);
};
return (
@@ -262,7 +262,7 @@ const VerInconsistencias = () => {
<CardTitle className="capitalize">
Inconsistências de {categoria}
<Badge variant="secondary" className="ml-2">
{(inconsistencias as any[]).length}
{(inconsistencias as InconsistenciaItem[]).length}
</Badge>
</CardTitle>
</CardHeader>
@@ -274,7 +274,7 @@ const VerInconsistencias = () => {
</div>
) : (
<div className="space-y-2">
{(inconsistencias as any[]).map((item, index) => (
{(inconsistencias as InconsistenciaItem[]).map((item, index) => (
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1">
<div className="font-medium">{item.descricao}</div>
-201
View File
@@ -1,201 +0,0 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Globe, Play, CheckCircle2, AlertCircle, RefreshCw, ExternalLink, Code } from "lucide-react";
import { toast } from "sonner";
export default function WebClone() {
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [activeTab, setActiveTab] = useState('clone');
const handleClone = async (e: React.FormEvent) => {
e.preventDefault();
if (!url) {
toast.error('Por favor, insira uma URL válida');
return;
}
setLoading(true);
toast.info(`Iniciando clonagem inteligente de ${url}...`);
try {
// Comunicando com a API do Crawler na porta 3001 (ou rota de gateway)
const res = await fetch('http://localhost:3001/api/crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ startUrl: url, maxPages: 5 })
});
if (!res.ok) {
throw new Error('Falha ao comunicar com o serviço de clonagem');
}
const data = await res.json();
setResult(data);
toast.success('Clonagem e recriação concluídas com sucesso!');
setActiveTab('resultados');
} catch (error: any) {
console.error(error);
toast.error(error.message || 'Erro ao realizar o clone da página');
} finally {
setLoading(false);
}
};
return (
<div className="container mx-auto p-6 space-y-6 max-w-7xl">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Globe className="h-8 w-8 text-primary" /> WebClone AI
</h1>
<p className="text-muted-foreground">
Clonagem inteligente avançada, mocking de SPAs e recriação generativa de componentes.
</p>
</div>
<Badge variant="outline" className="px-3 py-1 text-sm bg-primary/10 text-primary border-primary/20">
Playwright + IA + Service Workers
</Badge>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6">
<TabsList className="grid w-full md:w-[400px] grid-cols-2">
<TabsTrigger value="clone">Nova Clonagem</TabsTrigger>
<TabsTrigger value="resultados" disabled={!result}>Resultados</TabsTrigger>
</TabsList>
<TabsContent value="clone" className="space-y-6">
<Card className="border-border/50 shadow-sm backdrop-blur-sm bg-card/95">
<CardHeader>
<CardTitle>Iniciar Processo de Clonagem</CardTitle>
<CardDescription>
Insira a URL do site. O sistema irá extrair o HTML, processar o Shadow DOM, criar mocks de API XHR/Fetch e emular formulários via IA.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleClone} className="space-y-4">
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Globe className="absolute left-3 top-3 h-5 w-5 text-muted-foreground" />
<Input
type="url"
placeholder="https://exemplo.com.br"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="pl-10 h-12 text-base"
required
/>
</div>
<Button
type="submit"
size="lg"
disabled={loading}
className="h-12 px-6 font-semibold shadow-md hover:shadow-lg transition-all"
>
{loading ? (
<>
<RefreshCw className="mr-2 h-5 w-5 animate-spin" /> Processando...
</>
) : (
<>
<Play className="mr-2 h-5 w-5 fill-current" /> Clonar Site
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="border-border/50 bg-card/50 hover:bg-card/80 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2 text-primary">
<CheckCircle2 className="h-5 w-5" /> Mocks de Rede (SPA)
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Injeta um Service Worker local para responder chamadas XHR e Fetch gravadas durante a sessão original, permitindo que sites React/Vue funcionem offline.
</CardContent>
</Card>
<Card className="border-border/50 bg-card/50 hover:bg-card/80 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2 text-emerald-500">
<Code className="h-5 w-5" /> Recriação com IA
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Detecta formulários quebrados ou áreas dinâmicas vazias e usa LLMs multimodais para reescrever o componente em Vanilla JS/CSS funcional.
</CardContent>
</Card>
<Card className="border-border/50 bg-card/50 hover:bg-card/80 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2 text-amber-500">
<AlertCircle className="h-5 w-5" /> Declarative Shadow DOM
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Vasculha e abre as árvores de Shadow DOM ocultas, convertendo-as para templates declarativos para perfeita fidelidade visual.
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="resultados">
{result && (
<Card className="border-border/50 shadow-sm backdrop-blur-sm bg-card/95">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="h-6 w-6 text-emerald-500" /> Relatório do Clone
</CardTitle>
<CardDescription>Detalhes da extração e emulação do site {result.rootUrl}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-lg bg-muted/50 border border-border/50">
<div>
<div className="text-sm text-muted-foreground">Páginas Clonadas</div>
<div className="text-2xl font-bold text-primary">{result.totalPages || result.pages?.length || 1}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Tempo de Crawl</div>
<div className="text-2xl font-bold">{result.crawlTime ? `${(result.crawlTime / 1000).toFixed(1)}s` : 'N/A'}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Profundidade Máx</div>
<div className="text-2xl font-bold">{result.crawlDepth || 1}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Status</div>
<Badge variant="default" className="bg-emerald-500 text-white mt-1">Concluído</Badge>
</div>
</div>
<div className="flex gap-4">
<Button
onClick={() => window.open(`http://localhost:3000`, '_blank')}
className="flex items-center gap-2 bg-primary hover:bg-primary/90"
>
<ExternalLink className="h-4 w-4" /> Acessar Site Clonado Local
</Button>
<Button
variant="outline"
onClick={() => setActiveTab('clone')}
>
Clonar Outro Site
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
);
}
-1
View File
@@ -38,4 +38,3 @@ export { default as PrioridadesFabricacao } from './PrioridadesFabricacao';
export { default as MapaInterativo } from './MapaInterativo';
export { default as Atribuicoes } from './Atribuicoes';
export { default as VerInconsistencias } from './VerInconsistencias';
export { default as WebClone } from './WebClone';
+84 -101
View File
@@ -2,75 +2,79 @@ import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
export const generateProfessionalPDF = async (elementId: string, filename: string) => {
let tempContainer: HTMLElement | null = null;
try {
const element = document.getElementById(elementId);
if (!element) {
throw new Error('Elemento não encontrado para gerar PDF');
throw new Error(`Elemento #${elementId} não encontrado para gerar PDF`);
}
// Aguardar um pouco para garantir que o elemento esteja completamente renderizado
await new Promise(resolve => setTimeout(resolve, 200));
// Criar um container temporário isolado fora de modais/dialogs para evitar bugs de tamanho 0x0
tempContainer = document.createElement('div');
tempContainer.style.position = 'fixed';
tempContainer.style.left = '0';
tempContainer.style.top = '0';
tempContainer.style.width = '850px';
tempContainer.style.backgroundColor = '#ffffff';
tempContainer.style.zIndex = '-9999';
tempContainer.style.opacity = '1';
tempContainer.style.pointerEvents = 'none';
tempContainer.style.margin = '0';
tempContainer.style.padding = '20px';
tempContainer.style.boxSizing = 'border-box';
tempContainer.style.overflow = 'visible';
// Garantir que o elemento esteja visível e com dimensões corretas
const originalDisplay = element.style.display;
const originalVisibility = element.style.visibility;
const originalPosition = element.style.position;
// Clonar o elemento para o container isolado
const clonedElement = element.cloneNode(true) as HTMLElement;
clonedElement.style.display = 'block';
clonedElement.style.visibility = 'visible';
clonedElement.style.position = 'static';
clonedElement.style.width = '100%';
clonedElement.style.maxWidth = '100%';
clonedElement.style.height = 'auto';
clonedElement.style.maxHeight = 'none';
clonedElement.style.overflow = 'visible';
clonedElement.style.transform = 'none';
clonedElement.style.backgroundColor = '#ffffff';
clonedElement.style.color = '#000000';
element.style.display = 'block';
element.style.visibility = 'visible';
element.style.position = 'relative';
tempContainer.appendChild(clonedElement);
document.body.appendChild(tempContainer);
// Forçar um reflow
element.offsetHeight;
// Aguardar mais um pouco após forçar o reflow
// Aguardar renderização e computação de layout
await new Promise(resolve => setTimeout(resolve, 300));
console.log('Gerando PDF para elemento:', elementId);
console.log('Dimensões do elemento:', {
width: element.offsetWidth,
height: element.offsetHeight,
scrollWidth: element.scrollWidth,
scrollHeight: element.scrollHeight
});
// Se o elemento não tem dimensões, isso pode causar PDF em branco
if (element.offsetWidth === 0 || element.offsetHeight === 0) {
throw new Error('Elemento tem dimensões zero - não é possível gerar PDF');
}
// Elemento a ser renderizado pelo html2canvas
const targetElement = (clonedElement.offsetHeight > 0 && clonedElement.offsetWidth > 0)
? clonedElement
: element;
// Configurações otimizadas para html2canvas
const canvas = await html2canvas(element, {
const canvas = await html2canvas(targetElement, {
scale: 2,
useCORS: true,
allowTaint: false,
allowTaint: true,
backgroundColor: '#ffffff',
width: element.scrollWidth,
height: element.scrollHeight,
scrollX: 0,
scrollY: 0,
windowWidth: Math.max(element.scrollWidth, 1200),
windowHeight: Math.max(element.scrollHeight, 800),
foreignObjectRendering: false,
removeContainer: false,
imageTimeout: 10000,
logging: false
logging: false,
windowWidth: 1024,
onclone: (clonedDoc) => {
const found = clonedDoc.getElementById(elementId) || clonedDoc.body;
if (found) {
(found as HTMLElement).style.display = 'block';
(found as HTMLElement).style.visibility = 'visible';
}
}
});
console.log('Canvas criado com sucesso:', {
width: canvas.width,
height: canvas.height
});
// Verificar se o canvas foi criado corretamente
if (canvas.width === 0 || canvas.height === 0) {
throw new Error('Canvas criado com dimensões zero');
if (!canvas || canvas.width === 0 || canvas.height === 0) {
throw new Error('Não foi possível capturar o layout visual para o PDF');
}
// Restaurar estilos originais
element.style.display = originalDisplay;
element.style.visibility = originalVisibility;
element.style.position = originalPosition;
const imgData = canvas.toDataURL('image/png', 1.0);
if (!imgData || !imgData.startsWith('data:image/png;base64,')) {
throw new Error('Falha ao processar os dados da imagem para o PDF');
}
// Criar PDF com configurações otimizadas
const pdf = new jsPDF({
@@ -85,10 +89,10 @@ export const generateProfessionalPDF = async (elementId: string, filename: strin
const pageHeight = 297;
// Margens adequadas
const marginTop = 15;
const marginBottom = 15;
const marginLeft = 15;
const marginRight = 15;
const marginTop = 12;
const marginBottom = 12;
const marginLeft = 12;
const marginRight = 12;
// Área útil para conteúdo
const contentWidth = pageWidth - marginLeft - marginRight;
@@ -98,103 +102,82 @@ export const generateProfessionalPDF = async (elementId: string, filename: strin
const imgWidth = contentWidth;
const imgHeight = (canvas.height * contentWidth) / canvas.width;
// Converter canvas para imagem
const imgData = canvas.toDataURL('image/png', 1.0);
console.log('Adicionando imagem ao PDF:', {
imgWidth,
imgHeight,
contentHeight,
totalPages: Math.ceil(imgHeight / contentHeight)
});
// Verificar se os dados da imagem foram gerados
if (!imgData || imgData === 'data:,') {
throw new Error('Falha ao gerar dados da imagem do canvas');
}
// Sistema de paginação
let currentY = 0;
let pageNumber = 1;
const totalPages = Math.ceil(imgHeight / contentHeight);
const totalPages = Math.max(1, Math.ceil(imgHeight / contentHeight));
// Função para adicionar rodapé com numeração
const addFooter = (pageNum: number, totalPages: number) => {
const addFooter = (pageNum: number, totalPagesCount: number) => {
pdf.setFontSize(8);
pdf.setTextColor(100, 100, 100);
const footerText = `Página ${pageNum} de ${totalPages}`;
pdf.setTextColor(120, 120, 120);
const footerText = `Página ${pageNum} de ${totalPagesCount}`;
const textWidth = pdf.getTextWidth(footerText);
const footerX = (pageWidth - textWidth) / 2;
const footerY = pageHeight - 8;
const footerY = pageHeight - 6;
pdf.text(footerText, footerX, footerY);
};
// Primeira página
if (imgHeight <= contentHeight) {
// Conteúdo cabe em uma página
pdf.addImage(imgData, 'PNG', marginLeft, marginTop, imgWidth, imgHeight);
pdf.addImage(imgData, 'PNG', marginLeft, marginTop, imgWidth, imgHeight, undefined, 'FAST');
addFooter(1, 1);
} else {
// Conteúdo precisa de múltiplas páginas
// Conteúdo precisa de múltiplas páginas com corte preciso
while (currentY < imgHeight) {
if (pageNumber > 1) {
pdf.addPage();
}
// Calcular a altura restante do conteúdo
const remainingHeight = imgHeight - currentY;
const currentPageHeight = Math.min(contentHeight, remainingHeight);
// Criar um canvas temporário para a seção atual
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
if (tempCtx && currentPageHeight > 0) {
const sliceHeight = Math.round((currentPageHeight * canvas.width) / imgWidth);
tempCanvas.width = canvas.width;
tempCanvas.height = (currentPageHeight * canvas.width) / imgWidth;
tempCanvas.height = sliceHeight;
tempCtx.fillStyle = '#ffffff';
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
// Desenhar a seção atual do canvas original
tempCtx.drawImage(
canvas,
0,
(currentY * canvas.width) / imgWidth,
Math.round((currentY * canvas.width) / imgWidth),
canvas.width,
tempCanvas.height,
sliceHeight,
0,
0,
canvas.width,
tempCanvas.height
sliceHeight
);
// Converter para dados de imagem
const tempImgData = tempCanvas.toDataURL('image/png', 1.0);
// Adicionar a seção ao PDF
pdf.addImage(tempImgData, 'PNG', marginLeft, marginTop, imgWidth, currentPageHeight);
if (tempImgData && tempImgData.startsWith('data:image/png;base64,')) {
pdf.addImage(tempImgData, 'PNG', marginLeft, marginTop, imgWidth, currentPageHeight, undefined, 'FAST');
}
}
// Adicionar rodapé
addFooter(pageNumber, totalPages);
// Preparar para próxima página
currentY += contentHeight;
pageNumber++;
}
}
// Forçar o download do PDF
console.log('Iniciando download do PDF:', filename);
// Salvar arquivo PDF
pdf.save(filename);
// Aguardar um momento para garantir que o download seja iniciado
await new Promise(resolve => setTimeout(resolve, 500));
console.log('PDF salvo com sucesso:', filename);
return true;
} catch (error) {
} catch (error: any) {
console.error('Erro detalhado ao gerar PDF:', error);
throw new Error(`Erro ao gerar PDF: ${error.message}`);
throw new Error(`Erro ao gerar PDF: ${error.message || error}`);
} finally {
if (tempContainer && tempContainer.parentNode) {
tempContainer.parentNode.removeChild(tempContainer);
}
}
};
@@ -0,0 +1,92 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
const LOGTO_ENDPOINT = "https://logto-bzlued1boxl3t8ewsyn99an9.187.77.227.172.sslip.io";
const APP_ID = "rv73s8it14dkk6pdxegpy";
const APP_SECRET = "mB108PqAEa2pgTqNrBBdnVer12Sgf0pT";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const { email } = await req.json();
if (!email) {
return new Response(JSON.stringify({ error: "Email is required" }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 400,
});
}
// 1. Get Logto M2M Access Token
const tokenResponse = await fetch(`${LOGTO_ENDPOINT}/oidc/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${btoa(`${APP_ID}:${APP_SECRET}`)}`,
},
body: new URLSearchParams({
grant_type: "client_credentials",
resource: "https://default.logto.app/api",
scope: "all",
}).toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
throw new Error(`Failed to get M2M token: ${errorText}`);
}
const { access_token } = await tokenResponse.json();
// 2. Find User by Email
const searchResponse = await fetch(`${LOGTO_ENDPOINT}/api/users?search=${encodeURIComponent(email)}`, {
headers: {
Authorization: `Bearer ${access_token}`,
},
});
if (!searchResponse.ok) {
throw new Error(`Failed to search user: ${await searchResponse.text()}`);
}
const users = await searchResponse.json();
if (!users || users.length === 0) {
return new Response(JSON.stringify({ message: "User not found in Logto" }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 200,
});
}
const logtoUserId = users[0].id;
// 3. Delete User in Logto
const deleteResponse = await fetch(`${LOGTO_ENDPOINT}/api/users/${logtoUserId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${access_token}`,
},
});
if (!deleteResponse.ok) {
throw new Error(`Failed to delete user: ${await deleteResponse.text()}`);
}
return new Response(JSON.stringify({ message: "User deleted successfully in Logto" }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 200,
});
} catch (error) {
console.error("Error deleting Logto user:", error);
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 500,
});
}
});
@@ -0,0 +1,52 @@
-- Migration para corrigir a trigger atualizar_datas_reais_processo e prevenir erros 409 (Conflict) em inserções concorrentes de apontamentos
CREATE OR REPLACE FUNCTION public.atualizar_datas_reais_processo()
RETURNS TRIGGER AS $$
DECLARE
total_produzido NUMERIC;
total_planejado NUMERIC;
BEGIN
-- Se peca_id for nulo (ex: apontamento de componente sem peca_id vinculada), ignorar processamento de datas por peça
IF NEW.peca_id IS NULL THEN
RETURN NEW;
END IF;
-- Buscar quantidade total planejada da peça
SELECT COALESCE(quantidade, 0) INTO total_planejado
FROM public.pecas
WHERE id = NEW.peca_id;
-- Inserir novo registro ou atualizar existente usando ON CONFLICT para evitar erro 409 (Constraint UNIQUE peca_id + processo_id)
INSERT INTO public.processos_pecas_datas (
peca_id,
processo_id,
data_inicio_real,
quantidade_total_planejada,
quantidade_total_produzida
) VALUES (
NEW.peca_id,
NEW.processo_id,
NEW.data_apontamento,
total_planejado,
NEW.quantidade_produzida
)
ON CONFLICT (peca_id, processo_id) DO UPDATE
SET
quantidade_total_produzida = (
SELECT COALESCE(SUM(quantidade_produzida), 0)
FROM public.apontamentos_producao
WHERE peca_id = NEW.peca_id AND processo_id = NEW.processo_id
),
data_conclusao_real = CASE
WHEN (
SELECT COALESCE(SUM(quantidade_produzida), 0)
FROM public.apontamentos_producao
WHERE peca_id = NEW.peca_id AND processo_id = NEW.processo_id
) >= processos_pecas_datas.quantidade_total_planejada THEN NEW.data_apontamento
ELSE processos_pecas_datas.data_conclusao_real
END,
updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
@@ -0,0 +1,14 @@
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT conrelid::regclass AS table_name, conname AS constraint_name
FROM pg_constraint
WHERE confrelid = 'auth.users'::regclass
AND conrelid::regclass::text LIKE '"TS_ERP"%'
) LOOP
EXECUTE 'ALTER TABLE ' || r.table_name || ' DROP CONSTRAINT IF EXISTS ' || r.constraint_name || ' CASCADE;';
RAISE NOTICE 'Dropped constraint % on %', r.constraint_name, r.table_name;
END LOOP;
END $$;
@@ -0,0 +1,23 @@
-- Change pecas.peso_unitario to numeric(10,3)
ALTER TABLE "TS_ERP".pecas ALTER COLUMN peso_unitario TYPE numeric(10,3);
-- Fix the sync_of_data function to explicitly refer to TS_ERP.ordens_fabricacao
CREATE OR REPLACE FUNCTION "TS_ERP".sync_of_data()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
-- Atualizar dados na tabela ordens_fabricacao quando ficha_tecnica_contratos for alterada
IF TG_TABLE_NAME = 'ficha_tecnica_contratos' THEN
UPDATE "TS_ERP".ordens_fabricacao
SET
gestor = NEW.gestor,
data_termino_prev = NEW.data_termino_prev,
peso_total = NEW.quantidade,
descritivo = NEW.descricao_resumida
WHERE num_of = NEW.of_number;
END IF;
RETURN NEW;
END;
$function$;
@@ -0,0 +1,6 @@
-- Remover o gatilho que incrementava automaticamente a revisão do quadro de prioridades
DROP TRIGGER IF EXISTS trigger_increment_prioridade_fabricacao_revision ON "TS_ERP".itens_prioridade_fabricacao;
DROP TRIGGER IF EXISTS trigger_increment_prioridade_fabricacao_revision ON public.itens_prioridade_fabricacao;
-- Remover a função associada
DROP FUNCTION IF EXISTS public.increment_prioridade_fabricacao_revision();
+16
View File
@@ -0,0 +1,16 @@
import { createClient } from '@supabase/supabase-js';
import fs from 'fs';
const envContent = fs.readFileSync('.env', 'utf8');
const supabaseUrl = envContent.match(/VITE_SUPABASE_URL="(.*?)"/)[1];
const supabaseKey = envContent.match(/VITE_SUPABASE_PUBLISHABLE_KEY="(.*?)"/)[1];
const supabase = createClient(supabaseUrl, supabaseKey);
async function main() {
const { data: ofData } = await supabase.from('ordens_fabricacao').select('*').eq('num_of', 'B132').single();
console.log('OF:', ofData.peso_total);
const { data: pecas } = await supabase.from('pecas').select('peso_unitario, quantidade').eq('of_number', 'B132');
console.log('Pecas:', pecas.length > 0 ? pecas[0] : null);
}
main();
+17
View File
@@ -0,0 +1,17 @@
import { createClient } from '@supabase/supabase-js';
import fs from 'fs';
const envContent = fs.readFileSync('.env', 'utf8');
const supabaseUrl = envContent.match(/VITE_SUPABASE_URL="(.*?)"/)[1];
const supabaseKey = envContent.match(/VITE_SUPABASE_PUBLISHABLE_KEY="(.*?)"/)[1];
const supabase = createClient(supabaseUrl, supabaseKey);
async function main() {
const { data: ofData } = await supabase.from('ordens_fabricacao').select('*').limit(3);
console.log('OFs:', ofData.map(o => ({ num_of: o.num_of, peso_total: o.peso_total })));
const { data: pecas } = await supabase.from('pecas').select('of_number, peso_unitario, peso_total, quantidade').limit(3);
console.log('Pecas:', pecas);
}
main();

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