Compare commits

...

71 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
83 changed files with 7612 additions and 4233 deletions
+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.
+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();
+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 -3
View File
@@ -3,22 +3,42 @@ import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { useUserProfile } from '@/hooks/useUserProfile';
interface ProtectedAdminRouteProps { interface ProtectedAdminRouteProps {
children: React.ReactNode; children: React.ReactNode;
} }
export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ children }) => { export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ children }) => {
const { user, loading } = useAuth(); const { user, loading: authLoading } = useAuth();
const { isAdmin, loading: roleLoading } = useUserRole(); const { isAdmin, loading: roleLoading } = useUserRole();
const { profile, loading: profileLoading } = useUserProfile();
if (loading || roleLoading) { const loading = authLoading || roleLoading || profileLoading;
return <div>Carregando...</div>;
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) { if (!user) {
return <Navigate to="/auth" replace />; 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) { if (!isAdmin) {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
} }
+16 -3
View File
@@ -1,13 +1,17 @@
import React from 'react'; import React from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useUserProfile } from '@/hooks/useUserProfile';
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: React.ReactNode; children: React.ReactNode;
} }
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => { export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { user, loading } = useAuth(); const { user, loading: authLoading } = useAuth();
const { profile, loading: profileLoading } = useUserProfile();
const loading = authLoading || profileLoading;
// Mostrar loading enquanto carrega autenticação // Mostrar loading enquanto carrega autenticação
if (loading) { if (loading) {
@@ -23,7 +27,16 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
return <Navigate to="/auth" replace />; return <Navigate to="/auth" replace />;
} }
// Logto já valida o usuário via JWT/OIDC. // Verificar se o usuário está ativo no sistema
// Não precisa de profile separado no Supabase. 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>
);
}
return <>{children}</>; return <>{children}</>;
}; };
+18 -2
View File
@@ -3,6 +3,8 @@ import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { useUserProfile } from '@/hooks/useUserProfile';
interface ProtectedRouteByResourceProps { interface ProtectedRouteByResourceProps {
children: React.ReactNode; children: React.ReactNode;
resourceKey: string; resourceKey: string;
@@ -11,10 +13,13 @@ interface ProtectedRouteByResourceProps {
export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> = ({ export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> = ({
children, children,
}) => { }) => {
const { user, loading } = useAuth(); const { user, loading: authLoading } = useAuth();
const { isAdmin, loading: roleLoading } = useUserRole(); const { isAdmin, loading: roleLoading } = useUserRole();
const { profile, loading: profileLoading } = useUserProfile();
if (loading || roleLoading) { const loading = authLoading || roleLoading || profileLoading;
if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-muted-foreground">Carregando...</div> <div className="text-muted-foreground">Carregando...</div>
@@ -26,6 +31,17 @@ export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> =
return <Navigate to="/auth" replace />; 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>
);
}
// Migração pro Logto: todos usuários autenticados têm acesso aos recursos. // Migração pro Logto: todos usuários autenticados têm acesso aos recursos.
// Sistema de permissões granulares fica pra depois. // Sistema de permissões granulares fica pra depois.
return <>{children}</>; return <>{children}</>;
+143 -183
View File
@@ -13,7 +13,6 @@ import { usePecas } from '@/hooks/usePecas';
import { useOFs } from '@/hooks/useOFs'; import { useOFs } from '@/hooks/useOFs';
import { useComponentesAgrupados } from '@/hooks/useComponentesAgrupados'; import { useComponentesAgrupados } from '@/hooks/useComponentesAgrupados';
import { SeletorItensOtimizado } from './SeletorItensOtimizado'; import { SeletorItensOtimizado } from './SeletorItensOtimizado';
import { useApontamentosValidacao } from '@/hooks/useApontamentosValidacao';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface ItemDisponivel { interface ItemDisponivel {
@@ -23,9 +22,10 @@ interface ItemDisponivel {
tipo: 'peca' | 'componente'; tipo: 'peca' | 'componente';
quantidade_disponivel: number; quantidade_disponivel: number;
processo_atual_permitido: 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 = { const formCache = {
of_number: '', of_number: '',
fase: '', fase: '',
@@ -33,13 +33,6 @@ const formCache = {
data_apontamento: new Date().toISOString().split('T')[0] 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 = () => { export const ApontamentoForm = () => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
of_number: formCache.of_number || '', of_number: formCache.of_number || '',
@@ -52,55 +45,40 @@ export const ApontamentoForm = () => {
}); });
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null); const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
const [itensDisponiveis, setItensDisponiveis] = useState<{
pecasDisponiveis: ItemDisponivel[];
componentesDisponiveis: ItemDisponivel[];
}>({ pecasDisponiveis: [], componentesDisponiveis: [] });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [cacheValido, setCacheValido] = useState(false); const [cacheValido, setCacheValido] = useState(false);
const [loadingItens, setLoadingItens] = useState(false);
const [isProcessingItems, setIsProcessingItems] = useState(false);
const { criarApontamento, refetch, processos } = useApontamentosProducao(); const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos } = useApontamentosProducao();
const { pecas } = usePecas(); const { pecas, loading: loadingPecas } = usePecas();
const { ofs } = useOFs(); const { ofs } = useOFs();
const { componentesAgrupados } = useComponentesAgrupados(formData.of_number, formData.fase); const { componentesAgrupados, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
const {
validarSequenciaProcessos,
precarregarDados,
limparCache: limparCacheValidacao
} = useApontamentosValidacao();
// Buscar fases únicas da OF selecionada // 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 pecas
.filter(peca => peca.of_number === formData.of_number) .filter(peca => peca.of_number === formData.of_number)
.map(peca => peca.etapa_fase) .map(peca => peca.etapa_fase)
.filter((fase, index, array) => fase && array.indexOf(fase) === index) .filter(Boolean)
.sort(), )
[pecas, formData.of_number] ).sort();
); }, [pecas, formData.of_number]);
// Peças filtradas - memoizado para evite recálculos // Processo selecionado atualmente
const filteredPecas = useMemo(() => const processoSelecionado = useMemo(() => {
pecas.filter(peca => return processos.find(p => p.id === formData.processo_id) || null;
peca.of_number === formData.of_number && }, [processos, formData.processo_id]);
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]
);
// Salvar cache quando seleções básicas mudam // Salvar cache quando seleções básicas mudam
const updateCache = useCallback((updates: Partial<typeof formData>) => { const updateCache = useCallback((updates: Partial<typeof formData>) => {
Object.assign(formCache, updates); Object.assign(formCache, updates);
try {
localStorage.setItem('apontamento_cache', JSON.stringify(formCache)); localStorage.setItem('apontamento_cache', JSON.stringify(formCache));
} catch (e) {
console.warn('Erro ao salvar cache:', e);
}
}, []); }, []);
// Carregar cache inicial // Carregar cache inicial
@@ -124,120 +102,129 @@ export const ApontamentoForm = () => {
} }
}, []); }, []);
// Função para processar itens com cache // Cálculo reativo de peças e componentes disponíveis para o processo selecionado
const processarItensDisponiveis = useCallback(async () => { const itensDisponiveis = useMemo(() => {
const { of_number, fase, processo_id } = formData; const { of_number, fase, processo_id } = formData;
if (!of_number || !fase || !processo_id) { if (!of_number || !fase || !processo_id || !pecas.length) {
console.log('⚠️ Campos obrigatórios faltando para carregar itens'); return { pecasDisponiveis: [], componentesDisponiveis: [] };
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
return;
} }
// Verificar se já temos no cache (válido por 30 segundos) const ordemProcesso = processoSelecionado?.ordem || 1;
const cached = itensCache.get(cacheKey); const nomeProcesso = processoSelecionado?.nome || `${ordemProcesso}`;
const now = Date.now();
if (cached && (now - cached.timestamp) < 30000) { // Reordenar processos para identificar sequencialidade
console.log('📦 Usando itens do cache'); const processosOrdenados = [...processos].sort((a, b) => a.ordem - b.ordem);
setItensDisponiveis(cached); const indexAtual = processosOrdenados.findIndex(p => p.id === processo_id);
return; 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 (processoAnteriorValido) {
if (filteredPecas.length === 0 && componentesAgrupados.length === 0) { qtdDisponivelParaEntrar = apontamentos
console.log('⏳ Aguardando dados de peças e componentes...'); .filter(a => a.tipo_apontamento === 'peca' && a.peca_id === peca.id && a.processo_id === processoAnteriorValido.id)
return; .reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
}
} }
if (isProcessingItems) { const saldoDisponivel = Math.max(0, qtdDisponivelParaEntrar - totalApontadoAtual);
console.log('🔄 Já processando itens, aguardando...');
return;
}
console.log('\n🚀 === PROCESSANDO ITENS COM CACHE ==='); if (saldoDisponivel > 0) {
console.log(`📋 OF: ${of_number}, Fase: ${fase}, Processo: ${processo_id}`); pecasDisponiveis.push({
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 => ({
id: peca.id, id: peca.id,
marca: peca.marca, marca: peca.marca,
descricao: peca.descricao, descricao: peca.descricao || '',
tipo: 'peca' as const, tipo: 'peca',
quantidade_disponivel: peca.quantidade, quantidade_disponivel: saldoDisponivel,
processo_atual_permitido: 1 processo_atual_permitido: ordemProcesso,
})), nome_processo: nomeProcesso
componentesDisponiveis: componentesAgrupados.map(comp => ({ });
id: comp.componente_ids[0] || '', // Use primeiro ID do array }
});
// 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, marca: comp.marca_componente,
descricao: comp.descricao || '', descricao: comp.descricao || comp.perfil || '',
tipo: 'componente' as const, tipo: 'componente',
quantidade_disponivel: comp.quantidade_total, quantidade_disponivel: saldoComp,
processo_atual_permitido: 1 processo_atual_permitido: ordemProcesso,
})) nome_processo: nomeProcesso
};
console.log('✅ Itens calculados:', {
pecas: itens.pecasDisponiveis.length,
componentes: itens.componentesDisponiveis.length
}); });
}
// 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 return { pecasDisponiveis, componentesDisponiveis };
const updateData = useCallback(() => { }, [formData, pecas, apontamentos, processoSelecionado, componentesAgrupados, processos]);
// 4. Atualizar dados para nova seleção
console.log('✅ Dados atualizados para nova seleção de OF/processo');
}, []);
// Efeito controlado para carregar itens // Sincronizar item selecionado caso não exista mais na lista disponível
useEffect(() => { useEffect(() => {
if (formData.of_number && formData.fase && formData.processo_id) { if (itemSelecionado) {
// Usar timeout para evitar chamadas excessivas const listaAtual = itemSelecionado.tipo === 'peca'
const timeoutId = setTimeout(() => { ? itensDisponiveis.pecasDisponiveis
processarItensDisponiveis(); : itensDisponiveis.componentesDisponiveis;
}, 300);
return () => clearTimeout(timeoutId); const itemAindaExiste = listaAtual.find(i => i.id === itemSelecionado.id);
} else { if (!itemAindaExiste) {
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
}
}, [formData.of_number, formData.fase, formData.processo_id]);
// Reset do item selecionado quando dados mudam
useEffect(() => {
setItemSelecionado(null); setItemSelecionado(null);
setFormData(prev => ({ setFormData(prev => ({ ...prev, quantidade_produzida: '', todas_disponiveis: false }));
...prev, } else if (itemAindaExiste.quantidade_disponivel !== itemSelecionado.quantidade_disponivel) {
quantidade_produzida: '', setItemSelecionado(itemAindaExiste);
todas_disponiveis: false }
})); }
}, [formData.of_number, formData.fase, formData.processo_id]); }, [itensDisponiveis, itemSelecionado]);
// Auto-preenchimento da quantidade quando "todas disponíveis" é marcado // Auto-preenchimento da quantidade quando "todas disponíveis" é marcado
useEffect(() => { useEffect(() => {
@@ -259,9 +246,9 @@ export const ApontamentoForm = () => {
const tipoTexto = tipo === 'peca' ? 'peças' : 'componentes'; const tipoTexto = tipo === 'peca' ? 'peças' : 'componentes';
const confirmacao = window.confirm( 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` + `Total de itens: ${totalItens}\n` +
`Processo: ${formData.processo_id || 'N/A'}` `Processo: ${processoSelecionado?.nome || 'N/A'}`
); );
if (!confirmacao) return; if (!confirmacao) return;
@@ -273,7 +260,7 @@ export const ApontamentoForm = () => {
try { try {
for (const item of items) { for (const item of items) {
try { try {
const apontamentoData: any = { const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number, of_number: formData.of_number,
tipo_apontamento: item.tipo, tipo_apontamento: item.tipo,
processo_id: formData.processo_id, processo_id: formData.processo_id,
@@ -303,11 +290,8 @@ export const ApontamentoForm = () => {
if (sucessos > 0) { if (sucessos > 0) {
toast.success(`${sucessos} ${tipoTexto} registradas com sucesso!${erros > 0 ? ` (${erros} com erro)` : ''}`); toast.success(`${sucessos} ${tipoTexto} registradas com sucesso!${erros > 0 ? ` (${erros} com erro)` : ''}`);
await refetch();
await Promise.all([ resetFormForNewEntry();
refetch(),
resetFormForNewEntry()
]);
} else { } else {
toast.error(`Erro ao registrar ${tipoTexto} em lote`); toast.error(`Erro ao registrar ${tipoTexto} em lote`);
} }
@@ -331,9 +315,6 @@ export const ApontamentoForm = () => {
setFormData(prev => ({ ...prev, ...updates })); setFormData(prev => ({ ...prev, ...updates }));
updateCache({ of_number: ofNumber, fase: '', processo_id: '' }); updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
setItemSelecionado(null); setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
// Limpar cache relacionado
itensCache.clear();
}; };
const handleFaseChange = (fase: string) => { const handleFaseChange = (fase: string) => {
@@ -346,9 +327,6 @@ export const ApontamentoForm = () => {
setFormData(prev => ({ ...prev, ...updates })); setFormData(prev => ({ ...prev, ...updates }));
updateCache({ fase: fase, processo_id: '' }); updateCache({ fase: fase, processo_id: '' });
setItemSelecionado(null); setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
// Limpar cache relacionado
itensCache.clear();
}; };
const handleProcessoChange = (processoId: string) => { const handleProcessoChange = (processoId: string) => {
@@ -360,7 +338,6 @@ export const ApontamentoForm = () => {
setFormData(prev => ({ ...prev, ...updates })); setFormData(prev => ({ ...prev, ...updates }));
updateCache({ processo_id: processoId }); updateCache({ processo_id: processoId });
setItemSelecionado(null); setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
}; };
const handleItemSelect = (item: ItemDisponivel) => { const handleItemSelect = (item: ItemDisponivel) => {
@@ -387,10 +364,7 @@ export const ApontamentoForm = () => {
})); }));
}; };
// Função para resetar form e atualizar dados const resetFormForNewEntry = () => {
const resetFormForNewEntry = async () => {
console.log('🔄 Resetando formulário e limpando cache...');
setItemSelecionado(null); setItemSelecionado(null);
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
@@ -398,9 +372,6 @@ export const ApontamentoForm = () => {
observacoes: '', observacoes: '',
todas_disponiveis: false todas_disponiveis: false
})); }));
// Limpar cache e forçar recarregamento
itensCache.clear();
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -413,7 +384,7 @@ export const ApontamentoForm = () => {
const quantidade = parseInt(formData.quantidade_produzida); 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'); toast.error('Quantidade inválida');
return; return;
} }
@@ -421,16 +392,13 @@ export const ApontamentoForm = () => {
setSaving(true); setSaving(true);
try { try {
// Validação básica - pode ser expandida depois const apontamentoData: Parameters<typeof criarApontamento>[0] = {
console.log('✅ Validação de sequência aprovada');
const apontamentoData: any = {
of_number: formData.of_number, of_number: formData.of_number,
tipo_apontamento: itemSelecionado.tipo, tipo_apontamento: itemSelecionado.tipo,
processo_id: formData.processo_id, processo_id: formData.processo_id,
quantidade_produzida: quantidade, quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento, data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || null observacoes: formData.observacoes || undefined
}; };
if (itemSelecionado.tipo === 'componente') { if (itemSelecionado.tipo === 'componente') {
@@ -443,11 +411,8 @@ export const ApontamentoForm = () => {
if (result.success) { if (result.success) {
toast.success('Apontamento registrado com sucesso!'); toast.success('Apontamento registrado com sucesso!');
await refetch();
await Promise.all([ resetFormForNewEntry();
refetch(),
resetFormForNewEntry()
]);
} }
} catch (error) { } catch (error) {
console.error('Erro no submit:', error); console.error('Erro no submit:', error);
@@ -475,13 +440,11 @@ export const ApontamentoForm = () => {
todas_disponiveis: false todas_disponiveis: false
}); });
setItemSelecionado(null); setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
setCacheValido(false); setCacheValido(false);
itensCache.clear();
toast.success('Cache limpo com sucesso!'); toast.success('Cache limpo com sucesso!');
}; };
const processoSelecionado = null; const isLoadingItens = loadingPecas || loadingApontamentos || loadingComponentes;
return ( return (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
@@ -583,15 +546,12 @@ export const ApontamentoForm = () => {
<Alert> <Alert>
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertDescription> <AlertDescription>
{processoSelecionado.ordem === 1 {`Processo: ${processoSelecionado.ordem}. ${processoSelecionado.nome}. Selecione as peças ou componentes com saldo pendente para apontar.`}
? `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.`
}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{/* Seletor de itens otimizado com funcionalidade de lote - agora com scroll */} {/* Seletor de itens otimizado com funcionalidade de lote */}
{formData.processo_id && ( {formData.processo_id && (
<div className="max-h-96 overflow-y-auto"> <div className="max-h-96 overflow-y-auto">
<SeletorItensOtimizado <SeletorItensOtimizado
@@ -600,7 +560,7 @@ export const ApontamentoForm = () => {
itemSelecionado={itemSelecionado} itemSelecionado={itemSelecionado}
onItemSelect={handleItemSelect} onItemSelect={handleItemSelect}
onBatchSelect={handleBatchSelect} onBatchSelect={handleBatchSelect}
loading={loadingItens || isProcessingItems} loading={isLoadingItens}
/> />
</div> </div>
)} )}
@@ -660,7 +620,7 @@ export const ApontamentoForm = () => {
</h4> </h4>
{!itemSelecionado ? ( {!itemSelecionado ? (
<div className="text-sm text-muted-foreground"> <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>
) : ( ) : (
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
@@ -668,7 +628,7 @@ export const ApontamentoForm = () => {
<div><strong>Marca:</strong> {itemSelecionado.marca}</div> <div><strong>Marca:</strong> {itemSelecionado.marca}</div>
<div><strong>OF:</strong> {formData.of_number}</div> <div><strong>OF:</strong> {formData.of_number}</div>
<div><strong>Fase:</strong> {formData.fase}</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>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div> <div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
</div> </div>
@@ -676,7 +636,7 @@ export const ApontamentoForm = () => {
</CardContent> </CardContent>
</Card> </Card>
{/* Botões movidos para baixo do card de informações */} {/* Botões de ação */}
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
{formData.of_number && formData.fase && formData.processo_id && ( {formData.of_number && formData.fase && formData.processo_id && (
<Button <Button
@@ -690,7 +650,7 @@ export const ApontamentoForm = () => {
)} )}
<Button <Button
type="submit" type="submit"
disabled={saving || !itemSelecionado || !formData.processo_id || loadingItens || isProcessingItems} disabled={saving || !itemSelecionado || !formData.processo_id || isLoadingItens}
className="min-w-32" className="min-w-32"
> >
{saving ? 'Salvando...' : 'Registrar Apontamento'} {saving ? 'Salvando...' : 'Registrar Apontamento'}
@@ -42,9 +42,9 @@ export const ApontamentosListOtimizado: React.FC = () => {
.select(` .select(`
of_number, of_number,
quantidade_produzida, quantidade_produzida,
peca:pecas(marca), peca:pecas!apontamentos_producao_peca_id_fkey(marca),
componente:componentes_peca(marca_componente), componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente),
processo:processos_fabricacao(nome) processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome)
`) `)
.eq('id', apontamentoId) .eq('id', apontamentoId)
.single(); .single();
@@ -72,9 +72,10 @@ export const ApontamentosListOtimizado: React.FC = () => {
await refetch(); await refetch();
console.log('Lista de apontamentos atualizada'); console.log('Lista de apontamentos atualizada');
} catch (error: any) { } catch (error: unknown) {
console.error('Erro completo ao reverter apontamento:', error); 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
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -20,6 +20,7 @@ interface CronogramaFormProps {
cronograma?: CronogramaOf | null; cronograma?: CronogramaOf | null;
onClose: () => void; onClose: () => void;
isOpen: boolean; isOpen: boolean;
onSaveSuccess?: () => void;
} }
const processosDefault = [ const processosDefault = [
@@ -30,7 +31,19 @@ const processosDefault = [
'Aceite/DB' '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 { saveCronograma, getCronogramaPorOf } = useCronogramaOperations();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
of_id: '', of_id: '',
@@ -44,8 +57,8 @@ export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onCl
})) as ProcessoCronograma[] })) as ProcessoCronograma[]
}); });
const [ofs, setOfs] = useState<any[]>([]); const [ofs, setOfs] = useState<OFOption[]>([]);
const [gestores, setGestores] = useState<any[]>([]); const [gestores, setGestores] = useState<GestorOption[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
@@ -228,6 +241,9 @@ export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onCl
setLoading(false); setLoading(false);
if (success) { if (success) {
if (onSaveSuccess) {
onSaveSuccess();
}
onClose(); onClose();
} }
}; };
+324 -219
View File
@@ -1,5 +1,4 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect } from 'react';
import { CronogramaOf } from '@/hooks/useCronogramas'; import { CronogramaOf } from '@/hooks/useCronogramas';
import { useBrandSettings } from '@/hooks/useBrandSettings'; import { useBrandSettings } from '@/hooks/useBrandSettings';
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
@@ -15,30 +14,49 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
const { brandSettings } = useBrandSettings(); const { brandSettings } = useBrandSettings();
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => { 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 gerarPDF = useCallback(async () => {
const doc = new jsPDF('portrait', 'mm', 'a4'); // 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 pageWidth = doc.internal.pageSize.width; // 297mm
const corCinzaClaro = [200, 200, 200]; // Cinza claro para cabeçalho const pageHeight = doc.internal.pageSize.height; // 210mm
const corCinzaMedio = [150, 150, 150]; // Cinza médio const margin = 12;
const corCinzaEscuro = [80, 80, 80]; // Cinza escuro para texto const usableWidth = pageWidth - (margin * 2); // 273mm
const corBranco = [255, 255, 255]; // Branco
const pageWidth = doc.internal.pageSize.width; // Palette de Cores Executiva Modern (Otimizada para Impressora Monocromática)
const pageHeight = doc.internal.pageSize.height; const cPrimary = [241, 245, 249]; // Slate 100 (Fundo do cabeçalho)
const margin = 15; const cSecondary = [226, 232, 240]; // Slate 200
const usableWidth = pageWidth - (margin * 2); const cAccent = [71, 85, 105]; // Slate 600 (Highlight)
let yPosition = margin; 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 // 1. CABEÇALHO EXECUTIVO (HEADER SLATE DARK BANNER)
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]); // ----------------------------------------------------
doc.rect(0, 0, pageWidth, 25, 'F'); 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) { if (brandSettings.logo_url) {
try { try {
const img = new Image(); const img = new Image();
@@ -49,237 +67,324 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
img.src = brandSettings.logo_url!; img.src = brandSettings.logo_url!;
}); });
// Logo menor - 20x15 // Calcular aspect ratio para não espremer a imagem
const logoWidth = 20; const maxH = 16;
const logoHeight = 15; const maxW = 45;
doc.addImage(img, 'PNG', margin, 5, logoWidth, logoHeight); 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) { } catch (error) {
console.log('Erro ao carregar logo:', error); console.log('Erro ao carregar logo no PDF:', error);
} }
} }
// Nome da empresa e título // Título da Empresa & Documento
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]); doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
doc.setFontSize(14); doc.setFontSize(15);
doc.setFont('helvetica', 'bold'); 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.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
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.setFontSize(9); doc.setFontSize(9);
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
doc.text('RELATÓRIO EXECUTIVO DE CRONOGRAMA DE PRODUÇÃO', headerTextX, 19);
let xPosition = margin; // Badges no canto direito do cabeçalho
headers.forEach((header, index) => { const badgeY = 8;
doc.text(header, xPosition + 2, yPosition + 5); const badgeRight = pageWidth - margin;
xPosition += colWidths[index];
});
yPosition += 7; // Pill de OF
const ofNum = cronograma.ordem_fabricacao?.num_of || 'N/A';
// Linhas da tabela - altura reduzida em 40% (de 12 para 7.2) doc.setFillColor(cSecondary[0], cSecondary[1], cSecondary[2]);
doc.setFont('helvetica', 'normal'); 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.setFontSize(8);
doc.setFont('helvetica', 'bold');
doc.text(`OF: ${ofNum}`, badgeRight - 47.5, badgeY + 7.5, { align: 'center' });
cronograma.processos // Pill de Revisão
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0)) doc.setFillColor(cBgCard[0], cBgCard[1], cBgCard[2]);
.forEach((processo, index) => { doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
// Alternância de cores doc.roundedRect(badgeRight - 27, badgeY, 27, 12, 2, 2, 'FD');
if (index % 2 === 0) { doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
doc.setFillColor(250, 250, 250); doc.text(`REV: ${cronograma.revisao || 1}`, badgeRight - 13.5, badgeY + 7.5, { align: 'center' });
doc.rect(margin, yPosition, usableWidth, 7, 'F');
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 cardGap = 5;
const rowData = [ const cardWidth = (usableWidth - (cardGap * 3)) / 4;
processo.nome_processo.length > 25 ? processo.nome_processo.substring(0, 25) + '...' : processo.nome_processo, const cardHeight = 18;
format(parseISO(processo.data_inicio), 'dd/MM', { locale: ptBR }),
format(parseISO(processo.data_fim), 'dd/MM', { locale: ptBR }), const cardsData = [
`${calcularDiasCorridos(processo.data_inicio, processo.data_fim)}d` {
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]); cardsData.forEach((card, idx) => {
rowData.forEach((data, colIndex) => { const cardX = margin + idx * (cardWidth + cardGap);
doc.text(data, xPosition + 2, yPosition + 5);
xPosition += colWidths[colIndex];
});
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 // Card Title
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]); doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[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]);
doc.setFontSize(7); 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.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 // Borda ao redor das caixas
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]); doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F'); 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.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 doc.text(
const nomeArquivo = `cronograma_${cronograma.ordem_fabricacao?.num_of}_rev${cronograma.revisao}_${format(new Date(), 'ddMMyyyy')}.pdf`; `${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); doc.save(nomeArquivo);
if (onComplete) { if (onComplete) {
onComplete(); onComplete();
} }
}; }, [cronograma, onComplete, brandSettings]);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -287,7 +392,7 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
}, 100); }, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [cronograma, onComplete, brandSettings]); }, [gerarPDF]);
return null; return null;
}; };
@@ -1,9 +1,18 @@
import React, { useState, useMemo } from 'react';
import React, { useMemo } from 'react'; import {
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; ResponsiveContainer,
import { format, parseISO, eachDayOfInterval } from 'date-fns'; LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip
} from 'recharts';
import { format, parseISO } from 'date-fns';
import { ptBR } from 'date-fns/locale'; import { ptBR } from 'date-fns/locale';
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado'; import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
import { Button } from '@/components/ui/button';
import { Scale, Percent, FilterX } from 'lucide-react';
interface GraficoMestreProps { interface GraficoMestreProps {
processos: DashboardProcesso[]; processos: DashboardProcesso[];
@@ -11,126 +20,324 @@ interface GraficoMestreProps {
processoSelecionado?: string | null; 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> = ({ export const GraficoMestre: React.FC<GraficoMestreProps> = ({
processos, processos,
onProcessoClick, onProcessoClick,
processoSelecionado processoSelecionado: processoSelecionadoProp
}) => { }) => {
// Processar dados dos gráficos individuais para criar um gráfico sobreposto const [modoExibicao, setModoExibicao] = useState<'kg' | 'percent'>('kg');
const dadosGraficoSobreposto = useMemo(() => { const [selectedProcessoInternal, setSelectedProcessoInternal] = useState<string | null>(null);
if (!processos || processos.length === 0) return [];
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>(); const todasAsDatas = new Set<string>();
processos.forEach(processo => { processosOrdenados.forEach(processo => {
processo.dadosGrafico.forEach(ponto => { processo.dadosGrafico.forEach(ponto => {
todasAsDatas.add(ponto.data); todasAsDatas.add(ponto.data);
}); });
}); });
// Ordenar as datas
const datasOrdenadas = Array.from(todasAsDatas).sort(); const datasOrdenadas = Array.from(todasAsDatas).sort();
const ultimosValoresKg: Record<string, number> = {};
// Construir dados do gráfico sobreposto
return datasOrdenadas.map(data => { return datasOrdenadas.map(data => {
const pontoGrafico: any = { const dataFormatada = format(parseISO(data), 'dd/MM', { locale: ptBR });
data: format(parseISO(data), 'dd/MM', { locale: ptBR }), const pontoGrafico: Record<string, string | number> = {
data: dataFormatada,
dataCompleta: data dataCompleta: data
}; };
// Para cada processo, buscar o valor realizado na data processosOrdenados.forEach(processo => {
processos.forEach(processo => { const pontoProcesso = processo.dadosGrafico.find(p => p.data === data);
const pontoProcesso = processo.dadosGrafico.find(ponto => ponto.data === data);
// Converter para toneladas (dividir por 1000) let valorKg: number;
pontoGrafico[processo.nome] = pontoProcesso ? Math.round(pontoProcesso.realizado / 1000 * 100) / 100 : 0; 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; return pontoGrafico;
}); });
}, [processos]); }, [processosOrdenados, modoExibicao]);
// 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
];
return ( return (
<div className="w-full h-96"> <div className="space-y-4 w-full">
<ResponsiveContainer width="100%" height="100%"> {/* Barra Superior de Controles */}
<LineChart <div className="flex flex-wrap items-center justify-between gap-2 bg-muted/40 p-2.5 rounded-lg border border-border/50">
data={dadosGraficoSobreposto} <div className="flex items-center gap-2">
margin={{ top: 20, right: 30, left: 20, bottom: 5 }} <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 <XAxis
dataKey="data" dataKey="data"
stroke="#9ca3af" stroke="currentColor"
fontSize={12} fontSize={11}
tick={{ fill: '#9ca3af' }} className="text-muted-foreground"
tickLine={false}
axisLine={{ stroke: 'currentColor', opacity: 0.2 }}
/> />
<YAxis <YAxis
stroke="#9ca3af" stroke="currentColor"
fontSize={12} fontSize={11}
tick={{ fill: '#9ca3af' }} className="text-muted-foreground"
label={{ tickLine={false}
value: 'Peso Acumulado (t)', axisLine={{ stroke: 'currentColor', opacity: 0.2 }}
angle: -90, unit={modoExibicao === 'kg' ? ' kg' : '%'}
position: 'insideLeft', tickFormatter={(val) => modoExibicao === 'kg' && val >= 1000 ? `${(val / 1000).toFixed(1)}k` : `${val}`}
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)}
/> />
<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 <Line
key={processo.nome} key={processo.id}
type="monotone" type="monotone"
dataKey={processo.nome} dataKey={processo.nome}
stroke={obterCorProcesso(processo.status)} name={processo.nome}
strokeWidth={2} stroke={color}
dot={{ fill: obterCorProcesso(processo.status), strokeWidth: 2, r: 3 }} strokeWidth={isSelected ? 3.5 : isDimmed ? 1 : 2.5}
activeDot={{ r: 5, fill: obterCorProcesso(processo.status) }} strokeOpacity={isDimmed ? 0.2 : 1}
opacity={processoSelecionado ? (processoSelecionado === processo.nome ? 1 : 0.3) : 1} dot={isSelected ? { fill: color, r: 4.5, strokeWidth: 2, stroke: '#fff' } : false}
style={{ cursor: 'pointer' }} activeDot={{ r: 6, fill: color, stroke: '#fff', strokeWidth: 2 }}
connectNulls
/> />
))} );
})}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div>
); );
}; };
@@ -1,4 +1,3 @@
import React from 'react'; import React from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado'; import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
@@ -11,11 +10,16 @@ interface GraficoProgressoIndividualProps {
export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProps> = ({ processos }) => { export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProps> = ({ processos }) => {
const formatTooltipValue = (value: number, name: string) => [ const formatTooltipValue = (value: number, name: string) => [
`${(value / 1000).toFixed(2)} t`, `${Math.round(value).toLocaleString('pt-BR')} kg`,
name === 'planejado' ? 'Planejado' : 'Realizado' 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) => { const formatDateLabel = (tickItem: string) => {
try { try {
@@ -55,13 +59,13 @@ export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProp
<h3 className="text-lg font-semibold text-card-foreground"> <h3 className="text-lg font-semibold text-card-foreground">
Progresso - {processo.nome} Progresso - {processo.nome}
</h3> </h3>
<div className="ml-auto text-sm text-muted-foreground"> <div className="ml-auto text-sm text-muted-foreground font-mono">
{processo.progressoReal.toFixed(1)}% realizado {processo.progressoReal.toFixed(1)}% realizado ({Math.round(processo.pesoFabricado)} kg)
</div> </div>
</div> </div>
<div className="h-64 w-full"> <div className="h-64 w-full relative min-h-[256px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<AreaChart data={processo.dadosGrafico}> <AreaChart data={processo.dadosGrafico}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" /> <CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis <XAxis
@@ -61,10 +61,10 @@ export const ResumoOF: React.FC<ResumoOFProps> = ({ of, data, loading }) => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-card-foreground"> <div className="text-2xl font-bold text-card-foreground">
{(data.pesoTotalFabricado / 1000).toFixed(2)} t {data.pesoTotalFabricado.toFixed(2)} kg
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
de {(data.tonelagem / 1000).toFixed(2)} t contratadas de {data.tonelagem.toFixed(2)} kg contratadas
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -83,7 +83,7 @@ export const TabelaResumoProcessos: React.FC<TabelaResumoProcessosProps> = ({ da
{getStatusBadge(processo.status)} {getStatusBadge(processo.status)}
</TableCell> </TableCell>
<TableCell className="text-right py-2"> <TableCell className="text-right py-2">
{(processo.pesoFabricado / 1000).toFixed(3)} t {processo.pesoFabricado.toFixed(1)} kg
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
+6 -5
View File
@@ -46,17 +46,18 @@ export function UserInfo() {
useEffect(() => { useEffect(() => {
async function fetchUserProfile() { async function fetchUserProfile() {
if (!user) return; if (!user) return;
const { data, error } = await supabase const { data: profileArr, error } = await supabase
.from('profiles') .from('profiles')
.select('full_name, email, profile_image_url') .select('full_name, email, profile_image_url')
.eq('id', user.id) .eq('id', user.id)
.single(); .limit(1);
if (!error && data) { if (!error && profileArr && profileArr.length > 0) {
setProfile(data); setProfile(profileArr[0]);
} else { } else {
setProfile({ setProfile({
full_name: user.user_metadata?.full_name || null, // eslint-disable-next-line @typescript-eslint/no-explicit-any
full_name: (user as any).name || (user as any).username || null,
email: user.email || null, email: user.email || null,
profile_image_url: null profile_image_url: null
}); });
+73 -19
View File
@@ -104,8 +104,8 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
peca_id, peca_id,
quantidade_produzida, quantidade_produzida,
processo_id, processo_id,
processos_fabricacao!inner(nome), processo:processos_fabricacao!inner!apontamentos_producao_processo_id_fkey(nome),
pecas!inner( peca:pecas!inner!apontamentos_producao_peca_id_fkey(
id, id,
marca, marca,
descricao, descricao,
@@ -115,43 +115,80 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
of_number of_number
) )
`) `)
.eq('pecas.of_number', romaneio.of_number) .eq('peca.of_number', romaneio.of_number)
.eq('processos_fabricacao.nome', processo); .eq('processo.nome', processo);
if (error) { if (error) {
console.error('Erro ao buscar peças disponíveis:', error); console.error('Erro ao buscar peças disponíveis:', error);
return; 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>(); const pecasAgrupadas = new Map<string, PecaDisponivel>();
apontamentosData?.forEach(apontamento => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const pecaId = apontamento.pecas.id; (apontamentosData as any[])?.forEach((apontamento: any) => {
const pecaId = apontamento.peca?.id;
if (!pecaId) return;
const quantidade = apontamento.quantidade_produzida || 0; const quantidade = apontamento.quantidade_produzida || 0;
if (pecasAgrupadas.has(pecaId)) { if (pecasAgrupadas.has(pecaId)) {
const pecaExistente = pecasAgrupadas.get(pecaId)!; const pecaExistente = pecasAgrupadas.get(pecaId)!;
pecaExistente.quantidade_disponivel += quantidade; pecaExistente.quantidade_disponivel += quantidade; // Aqui salva o Bruto temporariamente
} else { } else {
pecasAgrupadas.set(pecaId, { pecasAgrupadas.set(pecaId, {
id: apontamento.pecas.id, id: apontamento.peca.id,
marca: apontamento.pecas.marca, marca: apontamento.peca.marca,
descricao: apontamento.pecas.descricao || '', descricao: apontamento.peca.descricao || '',
etapa_fase: apontamento.pecas.etapa_fase || '', etapa_fase: apontamento.peca.etapa_fase || '',
quantidade_disponivel: quantidade, quantidade_disponivel: quantidade, // Bruto temporário
peso_unitario: apontamento.pecas.peso_unitario || 0, peso_unitario: apontamento.peca.peso_unitario || 0,
prioridade: apontamento.pecas.prioridade || 'P4' prioridade: apontamento.peca.prioridade || 'P4'
}); });
} }
}); });
const pecasArray = Array.from(pecasAgrupadas.values()); // 4. Calcular o saldo Real (Produzido - Expedido) e limpar zerados
setPecasDisponiveis(pecasArray); 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 // Extrair fases e marcas únicas
const fasesUnicas = [...new Set(pecasArray.map(p => p.etapa_fase).filter(Boolean))]; const fasesUnicas = [...new Set(pecasDisponiveisFinal.map(p => p.etapa_fase).filter(Boolean))];
const marcasUnicas = [...new Set(pecasArray.map(p => p.marca).filter(Boolean))]; const marcasUnicas = [...new Set(pecasDisponiveisFinal.map(p => p.marca).filter(Boolean))];
setFasesDisponiveis(fasesUnicas); setFasesDisponiveis(fasesUnicas);
setMarcasDisponiveis(marcasUnicas); setMarcasDisponiveis(marcasUnicas);
@@ -180,6 +217,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
if (pecasError) { if (pecasError) {
console.error('Erro ao carregar peças do romaneio:', pecasError); console.error('Erro ao carregar peças do romaneio:', pecasError);
} else if (pecasData) { } else if (pecasData) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itensPecaFormatados = pecasData.map((item: any) => ({ const itensPecaFormatados = pecasData.map((item: any) => ({
id: item.id, id: item.id,
peca_id: item.peca_id, peca_id: item.peca_id,
@@ -203,6 +241,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
if (insumosError) { if (insumosError) {
console.error('Erro ao carregar insumos do romaneio:', insumosError); console.error('Erro ao carregar insumos do romaneio:', insumosError);
} else if (insumosData) { } else if (insumosData) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itensInsumoFormatados = insumosData.map((item: any) => ({ const itensInsumoFormatados = insumosData.map((item: any) => ({
id: item.id, id: item.id,
nome: item.descricao, nome: item.descricao,
@@ -225,6 +264,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
if (processoSelecionado && isOpen) { if (processoSelecionado && isOpen) {
buscarPecasDisponiveis(processoSelecionado); buscarPecasDisponiveis(processoSelecionado);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [processoSelecionado, isOpen, romaneio.of_number]); }, [processoSelecionado, isOpen, romaneio.of_number]);
// Definir processo padrão quando abrir o modal // Definir processo padrão quando abrir o modal
@@ -238,6 +278,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
setAdicionarTodas(false); setAdicionarTodas(false);
carregarItensRomaneio(); carregarItensRomaneio();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]); }, [isOpen]);
// Filtrar peças baseado nos filtros selecionados // Filtrar peças baseado nos filtros selecionados
@@ -306,6 +347,12 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
try { try {
const quantidade = parseInt(quantidadePeca); 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 const { error } = await supabase
.from('itens_romaneio_pecas') .from('itens_romaneio_pecas')
.insert({ .insert({
@@ -329,6 +376,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
// Recarregar itens e limpar formulário // Recarregar itens e limpar formulário
carregarItensRomaneio(); carregarItensRomaneio();
if (processoSelecionado) {
buscarPecasDisponiveis(processoSelecionado);
}
setPecaDisponivel(''); setPecaDisponivel('');
setQuantidadePeca(''); setQuantidadePeca('');
setAdicionarTodas(false); setAdicionarTodas(false);
@@ -374,6 +424,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
if (error) throw error; if (error) throw error;
toast.success('Item removido do romaneio'); toast.success('Item removido do romaneio');
carregarItensRomaneio(); carregarItensRomaneio();
if (processoSelecionado) {
buscarPecasDisponiveis(processoSelecionado);
}
} catch (error) { } catch (error) {
console.error('Erro ao remover item:', error); console.error('Erro ao remover item:', error);
toast.error('Erro ao remover item do romaneio'); toast.error('Erro ao remover item do romaneio');
@@ -572,6 +625,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
<Input <Input
type="number" type="number"
min="1" min="1"
max={pecasDisponiveis.find(p => p.id === pecaDisponivel)?.quantidade_disponivel || 1}
value={quantidadePeca} value={quantidadePeca}
onChange={(e) => setQuantidadePeca(e.target.value)} onChange={(e) => setQuantidadePeca(e.target.value)}
placeholder="Quantidade" 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); const normalizedMontagemData = normalizeData(montagemData);
return ( return (
<div style={{ fontFamily: 'Inter, sans-serif' }} className="h-full"> <div style={{ fontFamily: 'Inter, sans-serif' }} className="h-full flex flex-col pt-2 pb-3">
<div {/* Legenda Global */}
className="flex justify-evenly items-end h-full p-4 pt-6 pb-6 rounded-lg" <div className="flex justify-center gap-4 text-[10px] sm:text-xs text-muted-foreground mb-4">
style={{ height: '200px' }} <div className="flex items-center gap-1.5">
> <div className="w-3 h-3 bg-[#f97316] rounded-sm shadow-sm"></div>
{/* Grupo Corte */} <span className="font-medium">7 a 15 dias</span>
<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> </div>
<div <div className="flex items-center gap-1.5">
className="absolute w-full text-center text-foreground whitespace-nowrap" <div className="w-3 h-3 bg-[#10b981] rounded-sm shadow-sm"></div>
style={{ <span className="font-medium">Últimos 7 dias</span>
bottom: '-18px',
fontSize: '9px'
}}
>
{labels[index]}
</div> </div>
</div> <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
className="mt-4 text-muted-foreground font-medium"
style={{ fontSize: '11px' }}
>
Corte
</div> </div>
</div> </div>
{/* Grupo Solda */}
<div className="flex flex-col justify-end items-center h-full flex-1">
<div <div
className="flex items-end justify-center w-full gap-1" className="flex justify-evenly items-end h-full px-2"
style={{ height: '120px' }} 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 colors = ['#f97316', '#10b981', '#ec4899']; // orange, green, pink
const labels = ['7-15d', 'ult.7d', 'prev.7d'];
return ( return (
<div <div
key={index} key={index}
className="relative transition-all duration-500" className="relative transition-all duration-500 shadow-sm"
style={{ style={{
width: '20px', width: '24px',
height: `${calculateHeight(data.weight)}%`, height: `${calculateHeight(data.weight)}%`,
backgroundColor: colors[index], backgroundColor: colors[index],
borderRadius: '4px 4px 0 0' borderRadius: '4px 4px 0 0'
}} }}
> >
<div <div
className="absolute w-full text-center text-xs font-semibold text-foreground" className="absolute w-full text-center font-bold text-foreground"
style={{ style={{
top: '-20px', top: '-22px',
fontSize: '10px', fontSize: '11px',
lineHeight: '1.2' lineHeight: '1.2'
}} }}
> >
{formatValue(data.weight)} {formatValue(data.weight)}
</div> </div>
<div
className="absolute w-full text-center text-foreground whitespace-nowrap"
style={{
bottom: '-18px',
fontSize: '9px'
}}
>
{labels[index]}
</div>
</div> </div>
); );
})} })}
</div> </div>
<div <div className="mt-4 text-foreground font-semibold text-xs sm:text-sm border-t border-border pt-2 w-4/5 text-center">
className="mt-4 text-muted-foreground font-medium" {group.title}
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> </div>
</div> </div>
))}
</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()) canAutoFix: /^[\d,\.]+$/.test(value.toString())
}); });
convertedData[field] = 0; 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 { } else {
convertedData[field] = numValue; convertedData[field] = numValue;
} }
}
} else { } else {
convertedData[field] = field.includes('componente') ? 0 : (field === 'quantidade' ? 1 : 0); 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 { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card'; 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 { toast } from 'sonner';
import { Peca } from '@/hooks/usePecas'; import { Peca } from '@/hooks/usePecas';
import { ImportarPecasModal } from './ImportarPecasModal'; import { ImportarPecasModal } from './ImportarPecasModal';
import { ImportarXLSModal } from './ImportarXLSModal';
interface PecaFormProps { interface PecaFormProps {
ofNumbers: string[]; ofNumbers: string[];
ofDefault?: string;
onSave: (data: any) => Promise<boolean>; onSave: (data: any) => Promise<boolean>;
onUpdate: (id: string, data: any) => Promise<boolean>; onUpdate: (id: string, data: any) => Promise<boolean>;
onImportCSV: (file: File) => Promise<boolean>; onImportCSV: (file: File) => Promise<boolean>;
@@ -30,6 +32,7 @@ interface PecaFormProps {
export function PecaForm({ export function PecaForm({
ofNumbers, ofNumbers,
ofDefault,
onSave, onSave,
onUpdate, onUpdate,
onImportCSV, onImportCSV,
@@ -92,6 +95,7 @@ export function PecaForm({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showXLSModal, setShowXLSModal] = useState(false);
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -361,6 +365,16 @@ export function PecaForm({
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
Download Modelo CSV Download Modelo CSV
</Button> </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> </div>
<input <input
@@ -385,6 +399,13 @@ export function PecaForm({
etapa_fase: peca.etapa_fase etapa_fase: peca.etapa_fase
}))} }))}
/> />
<ImportarXLSModal
open={showXLSModal}
onOpenChange={setShowXLSModal}
onImport={onImportPecas}
ofDefault={formData.of_number || ofDefault || (ofNumbers && ofNumbers[0]) || ''}
/>
</div> </div>
); );
} }
+1 -1
View File
@@ -77,7 +77,7 @@ export function TableRow({
</TableCell> </TableCell>
<TableCell className="w-16 px-2 text-right font-medium text-foreground text-xs"> <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>
<TableCell className="w-16 px-2 text-muted-foreground text-xs"> <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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PrioridadesPDFTemplate } from './PrioridadesPDFTemplate'; import { PrioridadesPDFTemplate } from './PrioridadesPDFTemplate';
import { ItemPrioridade } from '@/hooks/useItensPrioridadeFabricacao'; import { ItemPrioridade } from '@/hooks/useItensPrioridadeFabricacao';
import { Download } from 'lucide-react'; import { Download, Loader2, Printer } from 'lucide-react';
import html2canvas from 'html2canvas'; import { generateProfessionalPDF, printProfessionalPDF } from '@/utils/pdfGenerator';
import jsPDF from 'jspdf'; import { toast } from 'sonner';
interface PrioridadesPDFProps { interface PrioridadesPDFProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
itensPorPrioridade: { [key: string]: ItemPrioridade[] }; itensPorPrioridade: { [key: string]: ItemPrioridade[] };
ofSelecionada?: string | null;
faseSelecionada?: string | null;
versaoAtual?: { versaoAtual?: {
revisao: number; revisao: number;
dataModificacao: string; dataModificacao: string;
@@ -23,52 +25,19 @@ export const PrioridadesPDF: React.FC<PrioridadesPDFProps> = ({
isOpen, isOpen,
onClose, onClose,
itensPorPrioridade, itensPorPrioridade,
ofSelecionada,
faseSelecionada,
versaoAtual versaoAtual
}) => { }) => {
const handleGerarPDF = async () => { const [isGenerating, setIsGenerating] = useState(false);
const elemento = document.getElementById('prioridades-pdf-content'); const [isPrinting, setIsPrinting] = useState(false);
if (!elemento) { const getNomeArquivo = () => {
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();
let nomeArquivo = 'checklist-producao'; let nomeArquivo = 'checklist-producao';
const todosItens = Object.values(itensPorPrioridade).flat();
if (todosItens.length > 0) {
const primeiroItem = todosItens[0]; const primeiroItem = todosItens[0];
const of = primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number; const of = ofSelecionada || primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number;
const fase = primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase; const fase = faseSelecionada || primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase;
if (of && fase) { if (of && fase) {
nomeArquivo = `checklist-producao-${of}-${fase}`; nomeArquivo = `checklist-producao-${of}-${fase}`;
@@ -76,11 +45,46 @@ export const PrioridadesPDF: React.FC<PrioridadesPDFProps> = ({
nomeArquivo += `-rev${versaoAtual.revisao}`; 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`); try {
} catch (error) { 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); 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}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center justify-between"> <DialogTitle className="flex flex-wrap items-center justify-between gap-2">
Visualizar Checklist de Produção <span>Visualizar Checklist de Produção</span>
<Button onClick={handleGerarPDF} className="ml-4"> <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" /> <Download className="h-4 w-4 mr-2" />
Baixar PDF Baixar PDF
</>
)}
</Button> </Button>
</div>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="mt-4"> <div className="mt-4">
<PrioridadesPDFTemplate <PrioridadesPDFTemplate
itensPorPrioridade={itensPorPrioridade} itensPorPrioridade={itensPorPrioridade}
ofSelecionada={ofSelecionada}
faseSelecionada={faseSelecionada}
versaoAtual={versaoAtual} versaoAtual={versaoAtual}
/> />
</div> </div>
@@ -4,6 +4,8 @@ import { ItemPrioridade } from '@/hooks/useItensPrioridadeFabricacao';
interface PrioridadesPDFTemplateProps { interface PrioridadesPDFTemplateProps {
itensPorPrioridade: { [key: string]: ItemPrioridade[] }; itensPorPrioridade: { [key: string]: ItemPrioridade[] };
ofSelecionada?: string | null;
faseSelecionada?: string | null;
versaoAtual?: { versaoAtual?: {
revisao: number; revisao: number;
dataModificacao: string; dataModificacao: string;
@@ -13,13 +15,15 @@ interface PrioridadesPDFTemplateProps {
export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
itensPorPrioridade, itensPorPrioridade,
ofSelecionada,
faseSelecionada,
versaoAtual versaoAtual
}) => { }) => {
const todosItens = Object.values(itensPorPrioridade).flat(); const todosItens = Object.values(itensPorPrioridade).flat();
const primeiroItem = todosItens[0]; const primeiroItem = todosItens[0];
const ofNumber = primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number || 'N/A'; const ofNumber = ofSelecionada || 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 etapaFase = faseSelecionada || primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase || 'N/A';
const dataAtual = new Date().toLocaleDateString('pt-BR'); const dataAtual = new Date().toLocaleDateString('pt-BR');
@@ -35,11 +39,11 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
const getCoresPrioridade = (codigo: string) => { const getCoresPrioridade = (codigo: string) => {
switch (codigo) { switch (codigo) {
case 'P1': return 'text-red-700 bg-red-100'; case 'P1': return 'text-red-700 bg-red-100 border-red-300';
case 'P2': return 'text-orange-700 bg-orange-100'; case 'P2': return 'text-orange-700 bg-orange-100 border-orange-300';
case 'P3': return 'text-blue-700 bg-blue-100'; case 'P3': return 'text-blue-700 bg-blue-100 border-blue-300';
case 'P4': return 'text-gray-700 bg-gray-200'; case 'P4': return 'text-gray-700 bg-gray-200 border-gray-300';
default: return 'text-gray-700 bg-gray-200'; 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 numBigBoxes = Math.floor(quantity / 5);
const numSmallBoxes = quantity % 5; const numSmallBoxes = quantity % 5;
// Quadrados grandes com "5" // Quadrados com "5"
for (let i = 0; i < numBigBoxes; i++) { for (let i = 0; i < numBigBoxes; i++) {
boxes.push( boxes.push(
<div key={`big-${i}`} className="tick-box-large"> <svg
<span>5</span> key={`big-${i}`}
</div> 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++) { 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 { } else {
// Apenas quadrados pequenos // Apenas quadrados unitários
for (let i = 0; i < quantity; i++) { 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 ( return (
<div id="prioridades-pdf-content" className="bg-white text-black max-w-4xl mx-auto p-6"> <div id="prioridades-pdf-content" className="bg-white text-black max-w-4xl mx-auto p-6">
<style>{` <style>{`
.tick-box { .checklist-container {
width: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
height: 12px; color: #111827;
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;
} }
.item-card { .item-card {
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
padding: 8px; padding: 8px 10px;
border-radius: 6px; 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 { @media print {
body { body {
font-size: 9px; font-size: 9px;
} }
.check-box-print {
border: 1px solid #333 !important;
}
.page-break { .page-break {
page-break-before: always; page-break-before: always;
} }
@@ -122,43 +143,61 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
} }
`}</style> `}</style>
<div className="checklist-container">
{/* Cabeçalho do Relatório */} {/* 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> <div>
<h1 className="text-2xl font-bold text-gray-900">Checklist de Produção</h1> <h1 className="text-2xl font-bold text-gray-900 leading-tight">Checklist de Produção</h1>
<p className="text-gray-600">Formulário para apontamento da fabricação.</p> <p className="text-xs text-gray-600">Formulário para apontamento da fabricação.</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="font-semibold"> <p className="font-semibold text-sm">
Data de Emissão: <span className="font-normal">{dataAtual}</span> Data de Emissão: <span className="font-normal">{dataAtual}</span>
{versaoAtual && ( {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> </p>
</div> </div>
</div> </div>
{/* Informações da OF e Fase */} {/* Informações da OF e Fase */}
<div className="border border-gray-200 bg-white p-4 rounded-lg mb-2"> <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-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-3">
{/* Coluna OF */} {/* Coluna OF */}
<div> <div>
<p className="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p> <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> </div>
{/* Coluna Fase */} {/* Coluna Fase */}
<div> <div>
<p className="text-xs font-medium text-gray-500">Fase</p> <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> </div>
{/* Coluna Processo */} {/* Coluna Processo */}
<div className="md:col-span-2"> <div className="md:col-span-2">
<p className="text-xs font-medium text-gray-500">PROCESSO</p> <p className="text-xs font-medium text-gray-500 mb-1">PROCESSO</p>
<div className="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1"> <div style={{ marginTop: '2px' }}>
{['Corte', 'Solda', 'Pintura', 'Expedição'].map((processo) => ( {['Corte', 'Solda', 'Pintura', 'Expedição'].map((processo) => (
<div key={processo} className="flex items-center gap-1"> <div
<div className="w-4 h-4 border-2 border-gray-500 check-box-print"></div> key={processo}
<span className="text-sm font-semibold text-gray-700">{processo}</span> 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>
))} ))}
</div> </div>
@@ -167,32 +206,39 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
</div> </div>
{/* Legenda */} {/* Legenda */}
<div className="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3"> <div className="text-xs text-gray-600 mb-5 flex items-center flex-wrap">
<span className="font-semibold">Legenda:</span> <span className="font-semibold" style={{ marginRight: '6px' }}>Legenda:</span>
<span>Marca (Qtd)</span> <span style={{ marginRight: '6px' }}>Marca (Qtd)</span>
<span className="font-medium text-gray-500">(S/M)</span> <span className="font-medium text-gray-500" style={{ marginRight: '4px' }}>(S/M)</span>
<span>= Sem Montagem,</span> <span style={{ marginRight: '6px' }}>= Sem Montagem,</span>
<span className="font-medium text-gray-500">(C/M)</span> <span className="font-medium text-gray-500" style={{ marginRight: '4px' }}>(C/M)</span>
<span>= Com Montagem. Os quadrados</span> <span style={{ marginRight: '4px' }}>= Com Montagem. Os quadrados</span>
<div className="tick-box inline-block"></div> <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> <span>indicam o controle de peças fabricadas.</span>
</div> </div>
{/* Itens por Prioridade */} {/* Itens por Prioridade */}
<div className="space-y-8"> <div className="space-y-6">
{['P1', 'P2', 'P3', 'P4'].map((codigo, priorityIndex) => { {['P1', 'P2', 'P3', 'P4'].map((codigo, priorityIndex) => {
const itens = itensPorPrioridade[codigo] || []; const itens = itensPorPrioridade[codigo] || [];
if (itens.length === 0) return null; if (itens.length === 0) return null;
return ( return (
<div key={codigo} className={priorityIndex > 0 ? 'page-break' : ''}> <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)} {getPrioridadeNome(codigo)}
</h2> </h2>
<div className="space-y-1"> <div className="space-y-1.5">
{Array.from({ length: Math.ceil(itens.length / 3) }, (_, i) => { {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); const rowItems = itens.slice(i * 3, (i + 1) * 3);
return ( return (
@@ -205,15 +251,17 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
return ( return (
<div key={item.id} className="item-card"> <div key={item.id} className="item-card">
<div className="flex items-center flex-wrap gap-2 mb-2"> <div style={{ marginBottom: '6px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<span className="font-semibold text-sm whitespace-nowrap"> <span style={{ fontSize: '13px', fontWeight: 700, color: '#111827', verticalAlign: 'middle', marginRight: '4px' }}>
{marca} ({quantidade}) {marca} ({quantidade})
</span> </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)} {generateTickBoxes(quantidade)}
</div> </div>
<div className="mt-2 text-xs"> <div className="item-signature">
<div className="border-b border-gray-400 pb-1 h-5">Data/Operador:</div> Data/Operador:
</div> </div>
</div> </div>
); );
@@ -232,5 +280,6 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
})} })}
</div> </div>
</div> </div>
</div>
); );
}; };
@@ -78,22 +78,26 @@ export const ApontamentoDiarioChart = () => {
return { valorMaximo, linhasGrid: linhas }; return { valorMaximo, linhasGrid: linhas };
}, [dadosProcessados]); }, [dadosProcessados]);
// Cores para cada OF // Array de cores profissionais e vibrantes para as OFs
const coresOF = { const availableColors = [
'B114': 'bg-blue-500', 'bg-blue-500',
'B117': 'bg-amber-500', 'bg-emerald-500',
'B118': 'bg-emerald-500', 'bg-violet-500',
'B119': 'bg-purple-500', 'bg-amber-500',
'B120': 'bg-pink-500', 'bg-rose-500',
'B121': 'bg-indigo-500', 'bg-cyan-500',
'B122': 'bg-cyan-500', 'bg-fuchsia-500',
'B123': 'bg-teal-500', 'bg-lime-500',
'B124': 'bg-lime-500', 'bg-indigo-500',
'B125': 'bg-red-500', 'bg-orange-500',
}; 'bg-teal-500',
'bg-pink-500'
];
const getCorOF = (ofNumber: string) => { 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 = [ const buttons = [
@@ -222,19 +226,19 @@ export const ApontamentoDiarioChart = () => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* Legenda interativa */} {/* 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) => ( {ofsUnicas.map((ofNumber) => (
<button <button
key={ofNumber} key={ofNumber}
onClick={() => setSelectedOF(selectedOF === ofNumber ? null : 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 selectedOF === ofNumber
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground border-primary scale-105 shadow-md'
: 'hover:bg-muted' : 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground'
}`} }`}
> >
<div className={`w-4 h-4 rounded-sm ${getCorOF(ofNumber)}`} /> <div className={`w-3 h-3 rounded-full shadow-inner ${getCorOF(ofNumber)}`} />
<span className="text-sm font-medium">OF {ofNumber}</span> <span className="text-sm font-semibold">OF {ofNumber}</span>
</button> </button>
))} ))}
{selectedOF && ( {selectedOF && (
@@ -262,7 +266,7 @@ export const ApontamentoDiarioChart = () => {
style={{ bottom: `${linha.porcentagem}%`, transform: 'translateY(50%)' }} style={{ bottom: `${linha.porcentagem}%`, transform: 'translateY(50%)' }}
> >
<span className="text-xs text-muted-foreground mr-2"> <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> </span>
<div className="w-2 h-px bg-border"></div> <div className="w-2 h-px bg-border"></div>
</div> </div>
@@ -315,7 +319,7 @@ export const ApontamentoDiarioChart = () => {
> >
{alturaPixels > 20 && ( {alturaPixels > 20 && (
<span className="transform rotate-90 whitespace-nowrap"> <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> </span>
)} )}
</div> </div>
@@ -47,14 +47,8 @@ export const RelatorioPecasProcessoModal: React.FC<RelatorioPecasProcessoModalPr
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto bg-card border-border"> <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto bg-card border-border">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center justify-between text-card-foreground"> <DialogTitle className="text-card-foreground">
<span>Relatório de Peças por Processo</span> Relatório de Peças por Processo
<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> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -33,37 +33,50 @@ export const RelatorioPecasProcessoPDF: React.FC<RelatorioPecasProcessoPDFProps>
} }
try { 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}`; const faseText = selectedFase === 'todas' ? '' : ` - Fase ${selectedFase}`;
// Título // 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.setFontSize(16);
doc.setFont('helvetica', 'bold'); 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.setFontSize(10);
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
let yPos = 65; doc.text(`OF: ${selectedOF}${faseText} | Gerado em: ${new Date().toLocaleString('pt-BR')}`, 14, 23);
doc.text(`Total de Peças: ${estatisticas.totalPecas}`, 20, yPos);
doc.text(`Peso Corte: ${estatisticas.pesoTotalCorte.toFixed(2)} kg`, 80, yPos); // Resumo por Processo em linha mais compacta
doc.text(`Peso Solda: ${estatisticas.pesoTotalSolda.toFixed(2)} kg`, 140, yPos); let yPos = 32;
doc.text(`Peso Pintura: ${estatisticas.pesoTotalPintura.toFixed(2)} kg`, 200, yPos); doc.setFontSize(9);
doc.text(`Peso Expedição: ${estatisticas.pesoTotalExpedicao.toFixed(2)} kg`, 260, yPos);
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 // Preparar dados para a tabela
const tableData = pecasComStatus.map(peca => [ const tableData = pecasComStatus.map(peca => [
@@ -88,36 +101,35 @@ export const RelatorioPecasProcessoPDF: React.FC<RelatorioPecasProcessoPDFProps>
autoTable(doc, { autoTable(doc, {
head: headers, head: headers,
body: tableData, body: tableData,
startY: yPos + 15, startY: yPos,
styles: { styles: {
fontSize: 9, fontSize: 8,
cellPadding: 2, cellPadding: 1.5,
lineColor: [204, 204, 204], lineColor: [210, 210, 210],
lineWidth: 0.5, lineWidth: 0.1,
}, },
headStyles: { headStyles: {
fillColor: [233, 236, 239], fillColor: [240, 240, 240],
textColor: 0, textColor: [40, 40, 40],
fontStyle: 'bold', fontStyle: 'bold',
lineColor: [204, 204, 204], lineWidth: 0.1,
lineWidth: 0.5,
}, },
alternateRowStyles: { alternateRowStyles: {
fillColor: [248, 249, 250], fillColor: [250, 250, 250],
}, },
columnStyles: { columnStyles: {
0: { cellWidth: 25, halign: 'center' }, // OF 0: { cellWidth: 18, halign: 'center' }, // OF
1: { cellWidth: 20, halign: 'center' }, // Fase 1: { cellWidth: 12, halign: 'center' }, // Fase
2: { cellWidth: 25, halign: 'center' }, // Marca 2: { cellWidth: 20, halign: 'center' }, // Marca
3: { cellWidth: 15, halign: 'center' }, // Qtd 3: { cellWidth: 10, halign: 'center' }, // Qtd
4: { cellWidth: 25, halign: 'right' }, // Peso Unit 4: { cellWidth: 22, halign: 'right' }, // Peso Unit
5: { cellWidth: 25, halign: 'right' }, // Peso Total 5: { cellWidth: 22, halign: 'right' }, // Peso Total
6: { cellWidth: 20, halign: 'center' }, // Corte 6: { cellWidth: 16, halign: 'center' }, // Corte
7: { cellWidth: 20, halign: 'center' }, // Solda 7: { cellWidth: 16, halign: 'center' }, // Solda
8: { cellWidth: 25, halign: 'center' }, // Pint/Galv 8: { cellWidth: 22, halign: 'center' }, // Pint/Galv
9: { cellWidth: 25, halign: 'center' }, // Expedição 9: { cellWidth: 22, halign: 'center' }, // Expedição
}, },
margin: { left: 20, right: 20 }, margin: { left: 14, right: 14, top: 20, bottom: 20 },
tableWidth: 'auto', tableWidth: 'auto',
}); });
@@ -41,15 +41,18 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
printTemplate.innerHTML = ` printTemplate.innerHTML = `
<style> <style>
@media print { @media print {
@page { size: A4 portrait; margin: 1cm; }
body { body {
margin: 0; margin: 0;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
background: white; background: white;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
} }
.a4-page { .a4-page {
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 1.5cm; padding: 0;
background: white; background: white;
} }
table { table {
@@ -58,18 +61,19 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
page-break-inside: auto; page-break-inside: auto;
} }
th, td { th, td {
border: 1px solid #ccc; border: 1px solid #e5e7eb;
padding: 4px 8px; padding: 3px 6px;
font-size: 9pt; font-size: 8pt;
text-align: center; text-align: center;
} }
th { th {
background-color: #e9ecef; background-color: #f3f4f6;
color: #374151;
font-weight: 600; font-weight: 600;
} }
.text-left { text-align: left; } .text-left { text-align: left; }
.text-right { text-align: right; } .text-right { text-align: right; }
.header-info, .summary-info { font-size: 10pt; } .header-info, .summary-info { font-size: 9pt; }
tr { page-break-inside: avoid; } tr { page-break-inside: avoid; }
thead { display: table-header-group; } thead { display: table-header-group; }
} }
@@ -81,7 +85,7 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
.a4-page { .a4-page {
width: 21cm; width: 21cm;
min-height: 29.7cm; min-height: 29.7cm;
padding: 1.5cm; padding: 1cm;
margin: 1cm auto; margin: 1cm auto;
background: white; background: white;
box-shadow: 0 0 10px rgba(0,0,0,0.1); box-shadow: 0 0 10px rgba(0,0,0,0.1);
@@ -91,44 +95,43 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
border-collapse: collapse; border-collapse: collapse;
} }
th, td { th, td {
border: 1px solid #ccc; border: 1px solid #e5e7eb;
padding: 4px 8px; padding: 3px 6px;
font-size: 9pt; font-size: 8pt;
text-align: center; text-align: center;
} }
th { th {
background-color: #e9ecef; background-color: #f3f4f6;
color: #374151;
font-weight: 600; font-weight: 600;
} }
.text-left { text-align: left; } .text-left { text-align: left; }
.text-right { text-align: right; } .text-right { text-align: right; }
.header-info, .summary-info { font-size: 10pt; } .header-info, .summary-info { font-size: 9pt; }
</style> </style>
<div class="a4-page"> <div class="a4-page">
<!-- Cabeçalho --> <!-- Cabeçalho -->
<header style="margin-bottom: 16px;"> <header style="margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid #ccc; padding-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 1px solid #ccc; padding-bottom: 4px;">
<div> <div>
<h1 style="font-size: 20px; font-weight: bold; color: #1f2937; margin: 0 0 4px 0;">Relatório de Status</h1> <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: 14px; color: #6b7280; margin: 0;" class="header-info">Ordem de Fabricação: <span style="font-weight: 600;">${selectedOF}${faseText}</span></p> <p style="font-size: 12px; color: #6b7280; margin: 0;" class="header-info">OF: <span style="font-weight: 600;">${selectedOF}${faseText}</span></p>
</div> </div>
<div style="text-align: right;"> <div style="text-align: right;">
<p style="font-size: 14px; color: #6b7280; margin: 0;" class="header-info">Gerado em:</p> <p style="font-size: 11px; color: #9ca3af; margin: 0;" class="header-info">Gerado em: ${currentDate}</p>
<p style="font-size: 14px; color: #9ca3af; margin: 0;" class="header-info">${currentDate}</p>
</div> </div>
</div> </div>
</header> </header>
<!-- Resumo --> <!-- Resumo -->
<section style="margin-bottom: 16px;"> <section style="margin-bottom: 12px;">
<h2 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #374151;">Resumo por Processo</h2> <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 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 Peças:</strong> ${estatisticas.totalPecas}</div>
<div><strong>Total de Peças:</strong> <span style="font-family: monospace;">${estatisticas.totalPecas}</span></div> <div><strong>Corte:</strong> ${estatisticas.pesoTotalCorte.toFixed(0)} kg</div>
<div><strong>Peso Corte:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalCorte.toFixed(2)} kg</span></div> <div><strong>Solda:</strong> ${estatisticas.pesoTotalSolda.toFixed(0)} kg</div>
<div><strong>Peso Solda:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalSolda.toFixed(2)} kg</span></div> <div><strong>Pintura:</strong> ${estatisticas.pesoTotalPintura.toFixed(0)} kg</div>
<div><strong>Peso Pintura:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalPintura.toFixed(2)} kg</span></div> <div><strong>Expedição:</strong> ${estatisticas.pesoTotalExpedicao.toFixed(0)} kg</div>
<div><strong>Peso Expedição:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalExpedicao.toFixed(2)} kg</span></div>
</div> </div>
</section> </section>
+29 -2
View File
@@ -20,7 +20,8 @@ import { UserProfile, UserFunction, UserPrivilege } from '@/hooks/useUserManagem
import { AvatarUpload } from '@/components/ui/avatar-upload'; import { AvatarUpload } from '@/components/ui/avatar-upload';
import { useProfileImage } from '@/hooks/useProfileImage'; import { useProfileImage } from '@/hooks/useProfileImage';
import { usePasswordManagement } from '@/hooks/usePasswordManagement'; 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 { interface UserModalProps {
user: UserProfile | null; user: UserProfile | null;
@@ -35,7 +36,9 @@ interface UserModalProps {
export function UserModal({ user, functions, privileges, onSave, onCreate, onClose, readOnly = false }: UserModalProps) { export function UserModal({ user, functions, privileges, onSave, onCreate, onClose, readOnly = false }: UserModalProps) {
const { updateProfileImage, removeProfileImage, updating } = useProfileImage(); const { updateProfileImage, removeProfileImage, updating } = useProfileImage();
const { changeUserPassword, isChangingPassword } = usePasswordManagement(); const { changeUserPassword, isChangingPassword } = usePasswordManagement();
const { deleteUserAndLogtoAccount } = useUserManagement();
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isDeletingLogto, setIsDeletingLogto] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: '', email: '',
full_name: '', full_name: '',
@@ -126,6 +129,18 @@ export function UserModal({ user, functions, privileges, onSave, onCreate, onClo
onClose(); 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 ( return (
<Dialog open={true} onOpenChange={onClose}> <Dialog open={true} onOpenChange={onClose}>
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-md"> <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 <Button
type="submit" type="submit"
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
disabled={updating || (isCreateMode && !formData.email)} disabled={updating || (isCreateMode && !formData.email) || isDeletingLogto}
> >
{isCreateMode ? 'Criar Usuário' : 'Salvar'} {isCreateMode ? 'Criar Usuário' : 'Salvar'}
</Button> </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}> <Button type="button" variant="ghost" onClick={onClose}>
{isReadOnly ? 'Fechar' : 'Cancelar'} {isReadOnly ? 'Fechar' : 'Cancelar'}
</Button> </Button>
+2 -2
View File
@@ -229,13 +229,13 @@ export const routeConfig: RouteDefinition[] = [
{ {
path: '/configuracoes', path: '/configuracoes',
component: () => import('@/pages/Configuracoes'), component: () => import('@/pages/Configuracoes'),
guard: 'resource', guard: 'admin',
resourceKey: 'configuracoes-gerais', resourceKey: 'configuracoes-gerais',
}, },
{ {
path: '/admin/theme-customization', path: '/admin/theme-customization',
component: () => import('@/pages/ThemeCustomizationPage'), component: () => import('@/pages/ThemeCustomizationPage'),
guard: 'resource', guard: 'admin',
resourceKey: 'theme-customization', resourceKey: 'theme-customization',
}, },
+4 -3
View File
@@ -86,9 +86,9 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
data_apontamento, data_apontamento,
quantidade_produzida, quantidade_produzida,
tipo_apontamento, tipo_apontamento,
processo:processos_fabricacao(nome, ordem), processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
peca:pecas(peso_unitario), peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
componente:componentes_peca(peso_unitario) componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario)
`) `)
.gte('data_apontamento', dataInicio) .gte('data_apontamento', dataInicio)
.lte('data_apontamento', dataFim) .lte('data_apontamento', dataFim)
@@ -216,6 +216,7 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [periodo]); }, [periodo]);
return { return {
+26 -18
View File
@@ -84,16 +84,8 @@ export const useApontamentosProducao = () => {
console.log('🔍 Iniciando busca COMPLETA de apontamentos...'); console.log('🔍 Iniciando busca COMPLETA de apontamentos...');
// Verificar se o usuário está autenticado // 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) { if (!user) {
console.warn('⚠️ Usuário não autenticado'); console.warn('⚠️ Usuário não autenticado em useApontamentosProducao');
toast.error('Usuário não autenticado');
return; return;
} }
@@ -121,7 +113,7 @@ export const useApontamentosProducao = () => {
console.log(`📊 TOTAL DE REGISTROS NA TABELA: ${totalCount}`); console.log(`📊 TOTAL DE REGISTROS NA TABELA: ${totalCount}`);
// SEGUNDA VERIFICAÇÃO: Buscar TODOS os apontamentos sem qualquer limitação // SEGUNDA VERIFICAÇÃO: Buscar TODOS os apontamentos sem qualquer limitação
let allApontamentos: any[] = []; let allApontamentos: ApontamentoProducao[] = [];
let pageNumber: number = 0; let pageNumber: number = 0;
const itemsPerPage: number = 1000; // Buscar em lotes de 1000 para evitar timeout const itemsPerPage: number = 1000; // Buscar em lotes de 1000 para evitar timeout
let hasMoreData: boolean = true; let hasMoreData: boolean = true;
@@ -137,9 +129,9 @@ export const useApontamentosProducao = () => {
.from('apontamentos_producao') .from('apontamentos_producao')
.select(` .select(`
*, *,
peca:pecas(marca, descricao, peso_unitario, etapa_fase), peca:pecas!apontamentos_producao_peca_id_fkey(marca, descricao, peso_unitario, etapa_fase),
processo:processos_fabricacao(nome, ordem), processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
componente:componentes_peca(marca_componente, descricao, peso_unitario) componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente, descricao, peso_unitario)
`) `)
.range(startIndex, endIndex) .range(startIndex, endIndex)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
@@ -156,7 +148,7 @@ export const useApontamentosProducao = () => {
break; 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}`); console.log(`✅ Página ${pageNumber + 1}: ${pageData.length} registros carregados. Total acumulado: ${allApontamentos.length}`);
// Se retornou menos que o itemsPerPage, chegamos ao fim // Se retornou menos que o itemsPerPage, chegamos ao fim
@@ -220,7 +212,7 @@ export const useApontamentosProducao = () => {
toast.error('Erro desconhecido ao carregar apontamentos'); toast.error('Erro desconhecido ao carregar apontamentos');
} }
} }
}, []); }, [user]);
const fetchProcessos = useCallback(async () => { const fetchProcessos = useCallback(async () => {
try { try {
@@ -325,7 +317,19 @@ export const useApontamentosProducao = () => {
console.log('🔄 Criando apontamento:', apontamento); 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, of_number: apontamento.of_number,
tipo_apontamento: apontamento.tipo_apontamento, tipo_apontamento: apontamento.tipo_apontamento,
processo_id: apontamento.processo_id, processo_id: apontamento.processo_id,
@@ -377,9 +381,13 @@ export const useApontamentosProducao = () => {
toast.success('Apontamento registrado com sucesso!'); toast.success('Apontamento registrado com sucesso!');
return { success: true }; return { success: true };
} catch (error) { } catch (error: any) {
console.error('❌ Erro ao criar apontamento:', error); 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 }; return { success: false, error };
} }
}, [user, fetchApontamentos]); }, [user, fetchApontamentos]);
@@ -203,9 +203,9 @@ export const useApontamentosProducaoOtimizado = () => {
.from('apontamentos_producao') .from('apontamentos_producao')
.select(` .select(`
*, *,
peca:pecas(marca, descricao), peca:pecas!apontamentos_producao_peca_id_fkey(marca, descricao),
processo:processos_fabricacao(nome, ordem), processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
componente:componentes_peca(marca_componente, descricao) componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente, descricao)
`) `)
.eq('of_number', ofNumber) .eq('of_number', ofNumber)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
+3 -3
View File
@@ -50,9 +50,9 @@ export const useAuditoriaInconsistencias = () => {
.from('apontamentos_producao') .from('apontamentos_producao')
.select(` .select(`
*, *,
processo:processos_fabricacao(nome, ordem), processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
peca:pecas(marca, etapa_fase, quantidade), peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase, quantidade),
componente:componentes_peca(marca_componente) componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente)
`) `)
.eq('of_number', ofNumber) .eq('of_number', ofNumber)
.then(({ data }) => data || []), .then(({ data }) => data || []),
+55 -15
View File
@@ -7,6 +7,7 @@ import React, {
useEffect, useEffect,
useState, useState,
useCallback, useCallback,
useRef,
ReactNode, ReactNode,
} from 'react'; } from 'react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
@@ -20,20 +21,49 @@ import {
requestPasswordReset, requestPasswordReset,
} from '@/lib/logto/client'; } from '@/lib/logto/client';
// Sincroniza user Logto com profiles Supabase // Sincroniza user Logto com profiles Supabase e retorna o UUID real do Supabase
async function syncUserToProfile(user: LogtoUser): Promise<void> { async function syncUserToProfile(user: LogtoUser): Promise<string> {
if (!user?.sub) return; if (!user?.sub) return user.sub;
const email = user.email || `${user.username || 'user'}@logto.local`;
try { try {
await supabase.from('profiles').upsert( // 1. Tenta buscar o perfil existente pelo email
{ const { data: existing } = await supabase
id: user.sub, .from('profiles')
full_name: user.name || user.email || user.username || 'Usuário', .select('id')
email: user.email || null, .eq('email', email)
}, .maybeSingle();
{ onConflict: 'id', ignoreDuplicates: false }
); 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) { } catch (err) {
console.warn('Erro ao sincronizar profile Supabase:', err); console.warn('Erro ao sincronizar profile Supabase:', err);
return user.sub;
} }
} }
@@ -56,17 +86,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [authInitialized, setAuthInitialized] = useState(false); const [authInitialized, setAuthInitialized] = useState(false);
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false); const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
const callbackHandled = useRef(false);
// Detecta callback URL e processa // Detecta callback URL e processa
useEffect(() => { useEffect(() => {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (url.searchParams.has('code')) { if (url.searchParams.has('code')) {
if (callbackHandled.current) return;
callbackHandled.current = true;
setLoading(true); setLoading(true);
handleCallback().then((ok) => { handleCallback().then((ok) => {
if (ok) { if (ok) {
logtoGetUser().then((u) => { logtoGetUser().then(async (u) => {
if (u) {
const profileId = await syncUserToProfile(u);
u.id = profileId;
}
setUser(u); setUser(u);
if (u) syncUserToProfile(u);
}); });
} }
setAuthInitialized(true); setAuthInitialized(true);
@@ -75,9 +112,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} else { } else {
setAuthInitialized(true); setAuthInitialized(true);
if (isAuthenticated()) { if (isAuthenticated()) {
logtoGetUser().then((u) => { logtoGetUser().then(async (u) => {
if (u) {
const profileId = await syncUserToProfile(u);
u.id = profileId;
}
setUser(u); setUser(u);
if (u) syncUserToProfile(u);
setLoading(false); setLoading(false);
}); });
} else { } else {
+1 -249
View File
@@ -1,249 +1 @@
export { useCronogramas as useCronogramaOperations } from './useCronogramas';
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
};
};
+47 -17
View File
@@ -47,7 +47,7 @@ export const useCronogramas = () => {
descritivo, descritivo,
peso_total peso_total
), ),
profiles!gestor_id ( profiles!cronogramas_of_gestor_id_fkey (
full_name, full_name,
email email
) )
@@ -89,23 +89,50 @@ export const useCronogramas = () => {
} }
}; };
const saveCronograma = async (cronograma: Omit<CronogramaOf, 'id'>) => { const saveCronograma = async (cronograma: Partial<CronogramaOf> & { processos: ProcessoCronograma[] }) => {
try { try {
if (!user) throw new Error('Usuário não autenticado'); 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 const { data: existingCronograma } = await supabase
.from('cronogramas_of') .from('cronogramas_of')
.select('id, revisao') .select('id, revisao')
.eq('of_id', cronograma.of_id) .eq('of_id', cronograma.of_id)
.single(); .maybeSingle();
let cronogramaId: string;
let novaRevisao = 1;
if (existingCronograma) { if (existingCronograma) {
// Atualizar cronograma existente // Atualizar cronograma existente
novaRevisao = existingCronograma.revisao + 1; const novaRevisao = (existingCronograma.revisao || 1) + 1;
const { data: updatedCronograma, error: updateError } = await supabase const { data: updatedCronograma, error: updateError } = await supabase
.from('cronogramas_of') .from('cronogramas_of')
@@ -144,14 +171,16 @@ export const useCronogramas = () => {
if (insertError) throw insertError; if (insertError) throw insertError;
cronogramaId = newCronograma.id; cronogramaId = newCronograma.id;
} }
}
// Inserir novos processos // 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, cronograma_id: cronogramaId,
nome_processo: processo.nome_processo, nome_processo: processo.nome_processo,
data_inicio: processo.data_inicio, data_inicio: processo.data_inicio,
data_fim: processo.data_fim, data_fim: processo.data_fim,
ordem: processo.ordem ordem: processo.ordem || idx + 1
})); }));
const { error: processosError } = await supabase const { error: processosError } = await supabase
@@ -159,13 +188,15 @@ export const useCronogramas = () => {
.insert(processosParaInserir); .insert(processosParaInserir);
if (processosError) throw processosError; if (processosError) throw processosError;
}
toast.success('Cronograma salvo com sucesso!'); toast.success('Cronograma salvo com sucesso!');
loadCronogramas(); await loadCronogramas();
return true; return true;
} catch (error) { } catch (error) {
console.error('Erro ao salvar cronograma:', 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; return false;
} }
}; };
@@ -180,7 +211,7 @@ export const useCronogramas = () => {
if (error) throw error; if (error) throw error;
toast.success('Cronograma removido com sucesso!'); toast.success('Cronograma removido com sucesso!');
loadCronogramas(); await loadCronogramas();
return true; return true;
} catch (error) { } catch (error) {
console.error('Erro ao deletar cronograma:', error); console.error('Erro ao deletar cronograma:', error);
@@ -200,16 +231,15 @@ export const useCronogramas = () => {
descritivo, descritivo,
peso_total peso_total
), ),
profiles!gestor_id ( profiles!cronogramas_of_gestor_id_fkey (
full_name, full_name,
email email
) )
`) `)
.eq('of_id', ofId) .eq('of_id', ofId)
.single(); .maybeSingle();
if (error && error.code !== 'PGRST116') throw error;
if (error) throw error;
if (!cronograma) return null; if (!cronograma) return null;
const { data: processos, error: processosError } = await supabase const { data: processos, error: processosError } = await supabase
+3 -2
View File
@@ -64,8 +64,8 @@ export const useDashboardProducao = (ofNumber: string) => {
.from('apontamentos_producao') .from('apontamentos_producao')
.select(` .select(`
*, *,
peca:pecas(peso_unitario), peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
processo:processos_fabricacao(nome, ordem) processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem)
`) `)
.eq('of_number', ofNumber); .eq('of_number', ofNumber);
@@ -172,6 +172,7 @@ export const useDashboardProducao = (ofNumber: string) => {
useEffect(() => { useEffect(() => {
fetchDashboardData(); fetchDashboardData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ofNumber]); }, [ofNumber]);
return { 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 { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -44,7 +44,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
const [dashboardData, setDashboardData] = useState<DashboardDataOtimizado | null>(null); const [dashboardData, setDashboardData] = useState<DashboardDataOtimizado | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fetchDashboardData = async () => { const fetchDashboardData = useCallback(async () => {
if (!ofNumber) { if (!ofNumber) {
setDashboardData(null); setDashboardData(null);
return; return;
@@ -102,6 +102,26 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
}, 0); }, 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'); console.log('Peso total planejado (BASE DE TODOS OS CÁLCULOS):', pesoTotalPlanejado, 'kg');
// 4. Buscar apontamentos da OF com joins corretos // 4. Buscar apontamentos da OF com joins corretos
@@ -109,9 +129,9 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
.from('apontamentos_producao') .from('apontamentos_producao')
.select(` .select(`
*, *,
peca:pecas(peso_unitario, marca), peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario, marca),
processo:processos_fabricacao(nome, ordem, cor), processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem, cor),
componente:componentes_peca(peso_unitario, marca_componente) componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario, marca_componente)
`) `)
.eq('of_number', ofNumber); .eq('of_number', ofNumber);
@@ -158,20 +178,24 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
console.log(`PROGRESSO GERAL: ${pesoTotalFabricado} kg / ${pesoTotalPlanejado} kg = ${progressoGeral.toFixed(2)}%`); console.log(`PROGRESSO GERAL: ${pesoTotalFabricado} kg / ${pesoTotalPlanejado} kg = ${progressoGeral.toFixed(2)}%`);
// 7. Buscar range de datas // 7. Buscar range de datas (opcional, ignora erro se a RPC não existir no Supabase)
const { data: dateRangeData, error: dateRangeError } = await supabase let dateRangeData = null;
try {
const { data, error } = await supabase
.rpc('get_dashboard_date_range', { of_number_param: ofNumber }); .rpc('get_dashboard_date_range', { of_number_param: ofNumber });
if (!error) dateRangeData = data;
if (dateRangeError) { } catch (e) {
console.error('Erro ao buscar range de datas:', dateRangeError); // Ignora caso RPC não exista
} }
// 8. Buscar dados consolidados // 8. Buscar dados consolidados (opcional, ignora erro se a RPC não existir no Supabase)
const { data: consolidatedData, error: consolidatedError } = await supabase let consolidatedData = null;
try {
const { data, error } = await supabase
.rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber }); .rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber });
if (!error) consolidatedData = data;
if (consolidatedError) { } catch (e) {
console.error('Erro ao buscar dados consolidados:', consolidatedError); // Ignora caso RPC não exista
} }
// 9. Buscar processos // 9. Buscar processos
@@ -207,7 +231,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
if (!cronogramaOf?.processos_cronograma) return 0; if (!cronogramaOf?.processos_cronograma) return 0;
const hoje = new Date(); const hoje = new Date();
let processoCronograma = null; let processoCronograma;
// Mapear nomes dos processos para os do cronograma // Mapear nomes dos processos para os do cronograma
if (nomeProcesso.toLowerCase().includes('corte') || nomeProcesso.toLowerCase().includes('solda')) { if (nomeProcesso.toLowerCase().includes('corte') || nomeProcesso.toLowerCase().includes('solda')) {
@@ -291,7 +315,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
const progressoEsperado = calcularProgressoEsperado(processo.nome); const progressoEsperado = calcularProgressoEsperado(processo.nome);
// Determinar status baseado na comparação entre progresso real e esperado // 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 // Se o progresso esperado chegou a 100% (passou da data fim), usar cor vermelha na barra
if (progressoEsperado >= 100) { if (progressoEsperado >= 100) {
@@ -330,7 +354,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
let dataFimProcesso: Date | null = null; let dataFimProcesso: Date | null = null;
if (cronogramaOf?.processos_cronograma) { if (cronogramaOf?.processos_cronograma) {
let processoCronograma = null; let processoCronograma;
// Mapear nomes dos processos para os do cronograma // Mapear nomes dos processos para os do cronograma
if (processo.nome.toLowerCase().includes('corte') || processo.nome.toLowerCase().includes('solda')) { 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 // Antes do início planejado, o planejado fica 0
// Calcular progresso real até esta data - soma dos pesos apontados // 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; let realizado = 0;
apontamentosAteData.forEach(a => { apontamentosAteData.forEach(a => {
let pesoUnitario = 0; let pesoUnitario = 0;
@@ -524,11 +552,11 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [ofNumber]);
useEffect(() => { useEffect(() => {
fetchDashboardData(); fetchDashboardData();
}, [ofNumber]); }, [fetchDashboardData]);
return { return {
dashboardData, dashboardData,
+4 -7
View File
@@ -77,6 +77,7 @@ export interface FichaTecnicaData {
doc_catalogo?: boolean; doc_catalogo?: boolean;
doc_fotos?: boolean; doc_fotos?: boolean;
/* eslint-disable @typescript-eslint/no-explicit-any */
// Informações do projeto // Informações do projeto
info_calculo_estrutural?: any; info_calculo_estrutural?: any;
info_projeto_basico?: any; info_projeto_basico?: any;
@@ -144,6 +145,7 @@ export interface FichaTecnicaData {
visto_exp?: string; visto_exp?: string;
visto_qual?: string; visto_qual?: string;
visto_colunas?: any; visto_colunas?: any;
/* eslint-enable @typescript-eslint/no-explicit-any */
} }
export function useFichaTecnica() { export function useFichaTecnica() {
@@ -160,7 +162,6 @@ export function useFichaTecnica() {
const { data, error } = await supabase const { data, error } = await supabase
.from('ficha_tecnica_contratos') .from('ficha_tecnica_contratos')
.select('*') .select('*')
.eq('user_id', user.id)
.order('of_number', { ascending: true }); .order('of_number', { ascending: true });
if (error) { if (error) {
@@ -182,7 +183,6 @@ export function useFichaTecnica() {
.from('ficha_tecnica_contratos') .from('ficha_tecnica_contratos')
.select('*') .select('*')
.eq('of_number', ofNumber.trim()) .eq('of_number', ofNumber.trim())
.eq('user_id', user.id)
.maybeSingle(); .maybeSingle();
if (error) { if (error) {
@@ -373,8 +373,7 @@ export function useFichaTecnica() {
const { error } = await supabase const { error } = await supabase
.from('ficha_tecnica_contratos') .from('ficha_tecnica_contratos')
.update(dataToSave) .update(dataToSave)
.eq('id', data.id) .eq('id', data.id);
.eq('user_id', user.id);
if (error) { if (error) {
console.error('Erro ao atualizar ficha técnica:', error); console.error('Erro ao atualizar ficha técnica:', error);
@@ -389,7 +388,6 @@ export function useFichaTecnica() {
.from('ficha_tecnica_contratos') .from('ficha_tecnica_contratos')
.select('id') .select('id')
.eq('of_number', data.of_number.trim()) .eq('of_number', data.of_number.trim())
.eq('user_id', user.id)
.maybeSingle(); .maybeSingle();
if (existingFicha) { if (existingFicha) {
@@ -397,8 +395,7 @@ export function useFichaTecnica() {
const { error } = await supabase const { error } = await supabase
.from('ficha_tecnica_contratos') .from('ficha_tecnica_contratos')
.update(dataToSave) .update(dataToSave)
.eq('id', existingFicha.id) .eq('id', existingFicha.id);
.eq('user_id', user.id);
if (error) { if (error) {
console.error('Erro ao atualizar ficha técnica existente:', error); console.error('Erro ao atualizar ficha técnica existente:', error);
+4 -3
View File
@@ -209,16 +209,17 @@ export function useInterfaceResources() {
.from('profiles') .from('profiles')
.select('privilege_id') .select('privilege_id')
.eq('id', userId) .eq('id', userId)
.single(); .limit(1);
if (profileError) throw profileError; 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 const { data, error } = await supabase
.from('privilege_interface_resources') .from('privilege_interface_resources')
.select('resource_key') .select('resource_key')
.eq('privilege_id', profile.privilege_id); .eq('privilege_id', priv);
if (error) throw error; if (error) throw error;
+8 -8
View File
@@ -44,14 +44,14 @@ export const useItensPrioridadeFabricacao = () => {
.from('itens_prioridade_fabricacao') .from('itens_prioridade_fabricacao')
.select(` .select(`
*, *,
peca:pecas(marca, descricao, peso_unitario, quantidade, tem_componentes, of_number, etapa_fase), peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(marca, descricao, peso_unitario, quantidade, tem_componentes, of_number, etapa_fase),
prioridade_fabricacao:prioridades_fabricacao( prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(
of_number, of_number,
etapa_fase, etapa_fase,
revisao, revisao,
data_ultima_modificacao, data_ultima_modificacao,
modificado_por, 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 }); .order('ordem_fabricacao', { ascending: true });
@@ -124,8 +124,8 @@ export const useItensPrioridadeFabricacao = () => {
.from('itens_prioridade_fabricacao') .from('itens_prioridade_fabricacao')
.select(` .select(`
*, *,
peca:pecas(of_number, etapa_fase, marca), peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(of_number, etapa_fase, marca),
prioridade_fabricacao:prioridades_fabricacao(of_number, etapa_fase) prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(of_number, etapa_fase)
`) `)
.eq('id', itemId) .eq('id', itemId)
.single(); .single();
@@ -215,8 +215,8 @@ export const useItensPrioridadeFabricacao = () => {
.select(` .select(`
id, id,
prioridade_fabricacao_id, prioridade_fabricacao_id,
peca:pecas(of_number, etapa_fase, marca), peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(of_number, etapa_fase, marca),
prioridade_fabricacao:prioridades_fabricacao(of_number, etapa_fase) prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(of_number, etapa_fase)
`) `)
.in('id', idsParaRemover); .in('id', idsParaRemover);
@@ -341,7 +341,7 @@ export const useItensPrioridadeFabricacao = () => {
.from('itens_prioridade_fabricacao') .from('itens_prioridade_fabricacao')
.select(` .select(`
*, *,
prioridade_fabricacao:prioridades_fabricacao( prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(
of_number, of_number,
etapa_fase etapa_fase
) )
@@ -54,7 +54,7 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
.select(` .select(`
revisao, revisao,
data_ultima_modificacao, data_ultima_modificacao,
profiles:modificado_por(full_name) profiles:prioridades_fabricacao_modificado_por_profiles_fkey(full_name)
`) `)
.eq('of_number', ofSelecionada) .eq('of_number', ofSelecionada)
.eq('etapa_fase', faseSelecionada) .eq('etapa_fase', faseSelecionada)
@@ -71,7 +71,7 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
setVersaoAtual({ setVersaoAtual({
revisao: data.revisao || 0, revisao: data.revisao || 0,
dataModificacao: data.data_ultima_modificacao || new Date().toISOString(), 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 { } else {
setVersaoAtual({ setVersaoAtual({
@@ -92,12 +92,49 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
setFaseSelecionada(novaFase); 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 { return {
...hookOriginal, ...hookOriginal,
itensPorPrioridade: itensFiltrados, itensPorPrioridade: itensFiltrados,
ofSelecionada, ofSelecionada,
faseSelecionada, faseSelecionada,
versaoAtual, versaoAtual,
onFiltroChange: handleFiltroChange onFiltroChange: handleFiltroChange,
incrementarRevisao
}; };
}; };
+2 -2
View File
@@ -363,8 +363,8 @@ export function usePecas() {
marca: item.marca || '', marca: item.marca || '',
descricao: item.descricao || '', descricao: item.descricao || '',
quantidade: Number(item.quantidade) || 0, quantidade: Number(item.quantidade) || 0,
peso_unitario: Math.round(Number(item.peso_unitario) || 0), peso_unitario: Number(item.peso_unitario) || 0,
peso_total: Math.round(Number(item.peso_total) || 0), peso_total: Number(item.peso_total) || 0,
tratamento_superficial: item.tratamento_superficial || '', tratamento_superficial: item.tratamento_superficial || '',
material: item.material || '', material: item.material || '',
perfil_principal: item.perfil_principal || '', perfil_principal: item.perfil_principal || '',
+4 -1
View File
@@ -177,7 +177,10 @@ export const usePecasTable = (pecas: Peca[]) => {
} }
return pecasParaCalcular.reduce((total, 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); }, 0);
}, [filteredAndSortedPecas, selectedPecas]); }, [filteredAndSortedPecas, selectedPecas]);
+53 -26
View File
@@ -44,51 +44,42 @@ export const useProcessChartData = (ofNumber: string) => {
console.log(`Processo ${processName} encontrado com ID:`, processo.id); console.log(`Processo ${processName} encontrado com ID:`, processo.id);
const hoje = new Date(); const hoje = new Date();
hoje.setHours(23, 59, 59, 999);
const seteDiasAtras = new Date(hoje); const seteDiasAtras = new Date(hoje);
seteDiasAtras.setDate(hoje.getDate() - 7); seteDiasAtras.setDate(hoje.getDate() - 7);
seteDiasAtras.setHours(0, 0, 0, 0);
const quatorzeDiasAtras = new Date(hoje); const quatorzeDiasAtras = new Date(hoje);
quatorzeDiasAtras.setDate(hoje.getDate() - 14); quatorzeDiasAtras.setDate(hoje.getDate() - 14);
quatorzeDiasAtras.setHours(0, 0, 0, 0);
const seisDiasAtras = new Date(hoje); const seisDiasAtras = new Date(hoje);
seisDiasAtras.setDate(hoje.getDate() - 6); 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 const { data: apontamentos, error: apontamentosError } = await supabase
.from('apontamentos_producao') .from('apontamentos_producao')
.select(` .select(`
*, *,
peca:pecas(peso_unitario), peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
componente:componentes_peca(peso_unitario) componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario)
`) `)
.eq('of_number', ofNumber) .eq('of_number', ofNumber)
.eq('processo_id', processo.id) .eq('processo_id', processo.id);
.gte('data_apontamento', quatorzeDiasAtras.toISOString().split('T')[0])
.lte('data_apontamento', hoje.toISOString().split('T')[0]);
if (apontamentosError) { if (apontamentosError) {
console.error(`Erro ao buscar apontamentos para ${processName}:`, 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); 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 peso7a14Dias = 0;
let peso6DiasRecentes = 0; let peso6DiasRecentes = 0;
let pesoTotalProcessado = 0;
if (apontamentos && apontamentos.length > 0) {
apontamentos.forEach(apontamento => { apontamentos.forEach(apontamento => {
let pesoUnitario = 0; let pesoUnitario = 0;
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) { 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 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 // Período de 7-14 dias atrás
if (dataApontamento >= quatorzeDiasAtras && dataApontamento < seteDiasAtras) { if (dataApontamento >= quatorzeDiasAtras && dataApontamento < seteDiasAtras) {
@@ -108,7 +107,9 @@ export const useProcessChartData = (ofNumber: string) => {
if (dataApontamento >= seisDiasAtras && dataApontamento <= hoje) { if (dataApontamento >= seisDiasAtras && dataApontamento <= hoje) {
peso6DiasRecentes += pesoTotal; peso6DiasRecentes += pesoTotal;
} }
}
}); });
}
// Calcular peso necessário para próximos 7 dias (meta) // Calcular peso necessário para próximos 7 dias (meta)
const { data: ofData } = await supabase const { data: ofData } = await supabase
@@ -117,12 +118,38 @@ export const useProcessChartData = (ofNumber: string) => {
.eq('num_of', ofNumber) .eq('num_of', ofNumber)
.single(); .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; let pesoMeta = 0;
if (ofData?.peso_total && ofData?.data_prazo) { if (pesoTotalPlanejado > 0) {
const dataFim = new Date(ofData.data_prazo); 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 diasRestantes = Math.max(1, Math.ceil((dataFim.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24)));
const pesoTotalProcessado = peso7a14Dias + peso6DiasRecentes; const pesoRestante = Math.max(0, pesoTotalPlanejado - pesoTotalProcessado);
const pesoRestante = Math.max(0, ofData.peso_total - pesoTotalProcessado);
// Meta para próximos 7 dias baseada no ritmo necessário // Meta para próximos 7 dias baseada no ritmo necessário
if (diasRestantes <= 7) { if (diasRestantes <= 7) {
@@ -133,7 +160,7 @@ export const useProcessChartData = (ofNumber: string) => {
} }
const resultado = [ 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: 'ult.7d', weight: Number(peso6DiasRecentes.toFixed(0)) },
{ name: 'prev.7d', weight: Number(pesoMeta.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, data_apontamento,
created_at, created_at,
tipo_apontamento, tipo_apontamento,
peca:pecas( peca:pecas!apontamentos_producao_peca_id_fkey(
id, id,
marca, marca,
descricao, descricao,
etapa_fase, etapa_fase,
peso_unitario peso_unitario
), ),
componente:componentes_peca( componente:componentes_peca!apontamentos_producao_componente_id_fkey(
id, id,
marca_componente, marca_componente,
descricao, descricao,
peso_unitario peso_unitario
), ),
processo:processos_fabricacao( processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(
nome nome
) )
`) `)
@@ -136,6 +136,7 @@ export const useRelatorioDiario = (selectedDate: string) => {
useEffect(() => { useEffect(() => {
fetchApontamentos(); fetchApontamentos();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDate]); }, [selectedDate]);
// Calcular resumo por processo // Calcular resumo por processo
+1 -1
View File
@@ -94,7 +94,7 @@ export const useRelatorioPecasProcesso = (ofNumber: string) => {
peca_id, peca_id,
processo_id, processo_id,
quantidade_produzida, quantidade_produzida,
processos_fabricacao!inner(nome) processos_fabricacao!apontamentos_producao_processo_id_fkey(nome)
`) `)
.eq('of_number', ofNumber) .eq('of_number', ofNumber)
.in('peca_id', pecaIds); .in('peca_id', pecaIds);
+42 -11
View File
@@ -22,7 +22,7 @@ export interface UserProfile {
privileges?: { privileges?: {
name: string; name: string;
description: string; description: string;
permissions: any; permissions: Record<string, unknown>;
}; };
} }
@@ -38,7 +38,7 @@ export interface UserPrivilege {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
permissions: any; permissions: Record<string, unknown>;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -51,6 +51,33 @@ export interface UserDependency {
} }
export function useUserManagement() { 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 { user } = useAuth();
const [users, setUsers] = useState<UserProfile[]>([]); const [users, setUsers] = useState<UserProfile[]>([]);
const [pendingUsers, setPendingUsers] = useState<UserProfile[]>([]); const [pendingUsers, setPendingUsers] = useState<UserProfile[]>([]);
@@ -171,6 +198,7 @@ export function useUserManagement() {
try { try {
const { data: result, error } = await supabase.rpc('admin_create_user', { const { data: result, error } = await supabase.rpc('admin_create_user', {
user_email: data.email, user_email: data.email,
_caller_id: user?.id,
user_full_name: data.full_name || null, user_full_name: data.full_name || null,
user_function_id: data.function_id || null, user_function_id: data.function_id || null,
user_privilege_id: data.privilege_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'); toast.success('Usuário criado com sucesso! Senha padrão: 1234');
fetchUsers(); fetchUsers();
return result; return result;
} catch (error: any) { } catch (error: unknown) {
console.error('Error creating user:', error); 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'); toast.error('Este e-mail já está em uso');
} else if (error.message?.includes('Only admins can create new users')) { } else if (error.message?.includes('Only admins can create new users')) {
toast.error('Apenas administradores podem criar usuários'); toast.error('Apenas administradores podem criar usuários');
@@ -230,6 +259,7 @@ export function useUserManagement() {
} }
const { data, error } = await supabase.rpc('admin_delete_user', { const { data, error } = await supabase.rpc('admin_delete_user', {
_caller_id: user?.id,
_user_id: userId _user_id: userId
}); });
@@ -239,12 +269,13 @@ export function useUserManagement() {
fetchUsers(); fetchUsers();
fetchPendingUsers(); fetchPendingUsers();
return true; return true;
} catch (error: any) { } catch (error: unknown) {
console.error('Error deleting user:', error); 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'); 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'); toast.error('Este usuário não pode ser excluído pois possui dados vinculados no sistema');
} else { } else {
toast.error('Erro ao excluir usuário'); 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 toggleUserStatus = async (userId: string, currentStatus: string) => {
const newStatus = currentStatus === 'active' ? 'inactive' : 'active'; 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 // CRUD functions for Functions table
@@ -367,7 +397,7 @@ export function useUserManagement() {
}; };
// CRUD functions for Privileges table - Updated to handle interface resources // 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 { try {
const { data: newPrivilege, error } = await supabase const { data: newPrivilege, error } = await supabase
.from('privileges') .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 { try {
const { error } = await supabase const { error } = await supabase
.from('privileges') .from('privileges')
@@ -458,6 +488,7 @@ export function useUserManagement() {
updateUser, updateUser,
toggleUserStatus, toggleUserStatus,
deleteUser, deleteUser,
deleteUserAndLogtoAccount,
canDeleteUser, canDeleteUser,
getUserDependencies, getUserDependencies,
replaceUserWithDeleted, replaceUserWithDeleted,
@@ -2,7 +2,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { import type {
UserPermissions, UserPermissions,
@@ -14,7 +13,6 @@ import type {
export function useUserPermissions() { export function useUserPermissions() {
const { user } = useAuth(); const { user } = useAuth();
const { isAdmin } = useUserRole();
const [userPermissions, setUserPermissions] = useState<UserPermissions>({ const [userPermissions, setUserPermissions] = useState<UserPermissions>({
can_admin: false, can_admin: false,
can_create_update_delete: false, can_create_update_delete: false,
@@ -88,10 +86,9 @@ export function useUserPermissions() {
loadUserPermissions(); loadUserPermissions();
}, [loadUserPermissions]); }, [loadUserPermissions]);
// Check if user has access to a resource
const hasAccess = useCallback((resourceKey?: string): boolean => { const hasAccess = useCallback((resourceKey?: string): boolean => {
// Admin always has access // Admin always has access
if (isAdmin) { if (userPermissions.can_admin) {
console.log(`✅ Admin access granted for resource: ${resourceKey || 'general'}`); console.log(`✅ Admin access granted for resource: ${resourceKey || 'general'}`);
return true; return true;
} }
@@ -124,7 +121,7 @@ export function useUserPermissions() {
const hasFunctionalAccess = Object.values(userPermissions).some(Boolean); const hasFunctionalAccess = Object.values(userPermissions).some(Boolean);
console.log(`🔄 Fallback functional access for ${resourceKey}: ${hasFunctionalAccess}`, userPermissions); console.log(`🔄 Fallback functional access for ${resourceKey}: ${hasFunctionalAccess}`, userPermissions);
return hasFunctionalAccess; return hasFunctionalAccess;
}, [isAdmin, userPermissions, resourcePermissions]); }, [userPermissions, resourcePermissions]);
// Check if user can access a specific route // Check if user can access a specific route
const canAccessRoute = useCallback((route: string): boolean => { const canAccessRoute = useCallback((route: string): boolean => {
@@ -148,10 +145,9 @@ export function useUserPermissions() {
return hasAccess(); return hasAccess();
}, [hasAccess]); }, [hasAccess]);
// Get permission level for a specific resource
const getResourcePermission = useCallback((resourceKey: ResourceKey): PermissionLevel => { const getResourcePermission = useCallback((resourceKey: ResourceKey): PermissionLevel => {
// Admin always has full permissions // Admin always has full permissions
if (isAdmin) return 'can_admin'; if (userPermissions.can_admin) return 'can_admin';
// Check specific resource permission // Check specific resource permission
if (resourcePermissions[resourceKey]) { if (resourcePermissions[resourceKey]) {
@@ -165,7 +161,7 @@ export function useUserPermissions() {
if (userPermissions.can_view_only) return 'can_view_only'; if (userPermissions.can_view_only) return 'can_view_only';
return 'no_access'; return 'no_access';
}, [isAdmin, userPermissions, resourcePermissions]); }, [userPermissions, resourcePermissions]);
// Check if user can perform a specific action on a resource // Check if user can perform a specific action on a resource
const canPerformActionByResource = useCallback(( const canPerformActionByResource = useCallback((
@@ -190,9 +186,8 @@ export function useUserPermissions() {
} }
}, [getResourcePermission]); }, [getResourcePermission]);
// Generic permission check for functional permissions
const canPerformAction = useCallback((action: ActionType): boolean => { const canPerformAction = useCallback((action: ActionType): boolean => {
if (isAdmin) return true; if (userPermissions.can_admin) return true;
switch (action) { switch (action) {
case 'admin': case 'admin':
@@ -208,7 +203,7 @@ export function useUserPermissions() {
default: default:
return false; return false;
} }
}, [isAdmin, userPermissions]); }, [userPermissions]);
// Set specific resource permission // Set specific resource permission
const setResourcePermission = useCallback(async ( const setResourcePermission = useCallback(async (
+9 -5
View File
@@ -7,6 +7,7 @@ interface UserProfile {
full_name: string | null; full_name: string | null;
email: string | null; email: string | null;
profile_image_url: string | null; profile_image_url: string | null;
status: string | null;
} }
export function useUserProfile() { export function useUserProfile() {
@@ -21,6 +22,7 @@ export function useUserProfile() {
setProfile(null); setProfile(null);
setLoading(false); setLoading(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]); }, [user]);
const fetchUserProfile = async () => { const fetchUserProfile = async () => {
@@ -30,7 +32,7 @@ export function useUserProfile() {
try { try {
const { data, error } = await supabase const { data, error } = await supabase
.from('profiles') .from('profiles')
.select('full_name, email, profile_image_url') .select('full_name, email, profile_image_url, status')
.eq('id', user.id) .eq('id', user.id)
.maybeSingle(); .maybeSingle();
@@ -39,18 +41,20 @@ export function useUserProfile() {
} else { } else {
// Fallback para dados do usuário auth // Fallback para dados do usuário auth
setProfile({ setProfile({
full_name: user.user_metadata?.full_name || null, full_name: user.name || user.username || null,
email: user.email || null, email: user.email || null,
profile_image_url: null profile_image_url: null,
status: null
}); });
} }
} catch (error) { } catch (error) {
console.error('Erro ao buscar perfil do usuário:', error); console.error('Erro ao buscar perfil do usuário:', error);
// Fallback para dados do usuário auth // Fallback para dados do usuário auth
setProfile({ setProfile({
full_name: user.user_metadata?.full_name || null, full_name: user.name || user.username || null,
email: user.email || null, email: user.email || null,
profile_image_url: null profile_image_url: null,
status: null
}); });
} finally { } finally {
setLoading(false); setLoading(false);
+9 -5
View File
@@ -18,6 +18,7 @@ export function useUserResourcePermissions(resourceKey: string) {
const [users, setUsers] = useState<UserWithProfile[]>([]); const [users, setUsers] = useState<UserWithProfile[]>([]);
const [resourcePermissions, setResourcePermissions] = useState<UserInterfacePermission[]>([]); const [resourcePermissions, setResourcePermissions] = useState<UserInterfacePermission[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const channelRef = useRef<any>(null); const channelRef = useRef<any>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
@@ -66,19 +67,20 @@ export function useUserResourcePermissions(resourceKey: string) {
// Enriquecer com dados do usuário // Enriquecer com dados do usuário
const enrichedData = await Promise.all( const enrichedData = await Promise.all(
(data || []).map(async (permission) => { (data || []).map(async (permission) => {
const { data: profileData } = await supabase const { data: profileDataArr } = await supabase
.from('profiles') .from('profiles')
.select('email, full_name') .select('email, full_name')
.eq('id', permission.user_id) .eq('id', permission.user_id)
.single(); .limit(1);
const profileData = profileDataArr && profileDataArr.length > 0 ? profileDataArr[0] : null;
return { return {
user_id: permission.user_id, user_id: permission.user_id,
resource_key: permission.resource_key, resource_key: permission.resource_key,
permission_level: permission.permission as PermissionLevel, permission_level: permission.permission as PermissionLevel,
created_at: permission.created_at, created_at: permission.created_at,
updated_at: permission.updated_at, updated_at: permission.updated_at,
profiles: profileData profiles: profileData as any
} as UserInterfacePermission; } as UserInterfacePermission;
}) })
); );
@@ -166,12 +168,13 @@ export function useUserResourcePermissions(resourceKey: string) {
} }
// Verify user exists // Verify user exists
const { data: userExists, error: userError } = await supabase const { data: userExistsArr, error: userError } = await supabase
.from('profiles') .from('profiles')
.select('id, email') .select('id, email')
.eq('id', userId) .eq('id', userId)
.single(); .limit(1);
const userExists = userExistsArr && userExistsArr.length > 0 ? userExistsArr[0] : null;
if (userError || !userExists) { if (userError || !userExists) {
console.error('User not found:', { userId, userError }); console.error('User not found:', { userId, userError });
toast.error('Usuário não encontrado'); toast.error('Usuário não encontrado');
@@ -341,6 +344,7 @@ export function useUserResourcePermissions(resourceKey: string) {
mountedRef.current = false; mountedRef.current = false;
cleanupChannel(); cleanupChannel();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, isAdmin, resourceKey]); }, [user?.id, isAdmin, resourceKey]);
// Cleanup on unmount // Cleanup on unmount
+15 -10
View File
@@ -1,14 +1,17 @@
// Hook que retorna role/permissões do usuário // Hook que retorna role/permissões do usuário
// Migrado pra Logto: não depende mais do Supabase // Migrado pra consumir as permissões reais do banco via useUserPermissions
// Qualquer usuário autenticado via Logto tem acesso Total
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useUserPermissions } from '@/hooks/useUserPermissions';
import type { UserRole, AppRole } from '@/hooks/useUserPermissions/types'; import type { UserRole, AppRole } from '@/hooks/useUserPermissions/types';
export type AccessLevel = 'Total' | 'Parcial' | 'Restrita'; export type AccessLevel = 'Total' | 'Parcial' | 'Restrita';
export const useUserRole = () => { export const useUserRole = () => {
const { user, loading } = useAuth(); const { user, loading: authLoading } = useAuth();
const { userPermissions, loading: permsLoading } = useUserPermissions();
const loading = authLoading || permsLoading;
// Sem usuário = sem permissão // Sem usuário = sem permissão
if (!user) { if (!user) {
@@ -18,18 +21,20 @@ export const useUserRole = () => {
isGerencia: false, isGerencia: false,
isDiretoria: false, isDiretoria: false,
role: 'user' as UserRole, role: 'user' as UserRole,
loading: false, loading,
error: null, error: null,
}; };
} }
// Com usuário autenticado via Logto = Total // Com usuário autenticado + validações do json de privilégios do DB
const isAdminCheck = Boolean(userPermissions.can_admin);
return { return {
accessLevel: 'Total' as AccessLevel, accessLevel: isAdminCheck ? 'Total' : 'Parcial' as AccessLevel,
isAdmin: true, isAdmin: isAdminCheck,
isGerencia: true, isGerencia: isAdminCheck, // fallback para componentes legados
isDiretoria: true, isDiretoria: isAdminCheck, // fallback para componentes legados
role: 'admin' as UserRole, role: (isAdminCheck ? 'admin' : 'user') as UserRole,
loading, loading,
error: null, error: null,
}; };
+41 -35
View File
@@ -41,8 +41,8 @@ export const useValidacaoSequencialProcessos = () => {
quantidade_produzida, quantidade_produzida,
processo_id, processo_id,
tipo_apontamento, tipo_apontamento,
peca:pecas(marca), peca:pecas!apontamentos_producao_peca_id_fkey(marca),
processo:processos_fabricacao(ordem) processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(ordem)
`) `)
.eq('of_number', ofNumber) .eq('of_number', ofNumber)
.eq('tipo_apontamento', 'peca'); .eq('tipo_apontamento', 'peca');
@@ -54,8 +54,8 @@ export const useValidacaoSequencialProcessos = () => {
quantidade_produzida, quantidade_produzida,
processo_id, processo_id,
tipo_apontamento, tipo_apontamento,
componente:componentes_peca(marca_componente), componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente),
processo:processos_fabricacao(ordem) processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(ordem)
`) `)
.eq('of_number', ofNumber) .eq('of_number', ofNumber)
.eq('tipo_apontamento', 'componente'); .eq('tipo_apontamento', 'componente');
@@ -163,15 +163,33 @@ export const useValidacaoSequencialProcessos = () => {
return { valido: true }; return { valido: true };
}, [historico, processos]); }, [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 // Calcular itens disponíveis para um processo específico
const calcularItensDisponiveis = useCallback(async ( const calcularItensDisponiveis = useCallback(async (
ofNumber: string, ofNumber: string,
processoId: string, processoId: string,
pecas: any[], pecas: Record<string, unknown>[],
componentes: any[] componentes: Record<string, unknown>[]
): Promise<{ ): Promise<{
pecasDisponiveis: any[]; pecasDisponiveis: Record<string, unknown>[];
componentesDisponiveis: any[]; componentesDisponiveis: Record<string, unknown>[];
}> => { }> => {
const processoAtual = processos.find(p => p.id === processoId); const processoAtual = processos.find(p => p.id === processoId);
if (!processoAtual) { if (!processoAtual) {
@@ -179,28 +197,31 @@ export const useValidacaoSequencialProcessos = () => {
} }
const ordemAtual = processoAtual.ordem; const ordemAtual = processoAtual.ordem;
const pecasDisponiveis: any[] = []; const pecasDisponiveis: Record<string, unknown>[] = [];
const componentesDisponiveis: any[] = []; const componentesDisponiveis: Record<string, unknown>[] = [];
// Processar peças // Processar peças
for (const peca of pecas) { for (const peca of pecas) {
const marcaPeca = String(peca.marca || '');
const qtdPeca = Number(peca.quantidade || 0);
if (ordemAtual === 1) { if (ordemAtual === 1) {
// Primeiro processo: todas as peças estão disponíveis // Primeiro processo: todas as peças estão disponíveis
pecasDisponiveis.push({ pecasDisponiveis.push({
...peca, ...peca,
quantidade_disponivel: peca.quantidade || 0 quantidade_disponivel: qtdPeca
}); });
} else { } else {
// Calcular quantidade disponível baseada no processo anterior // Calcular quantidade disponível baseada no processo anterior
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior( const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
peca.marca, marcaPeca,
ordemAtual - 1, ordemAtual - 1,
ofNumber, ofNumber,
'peca' 'peca'
); );
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada( const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
peca.marca, marcaPeca,
processoId, processoId,
ofNumber, ofNumber,
'peca' 'peca'
@@ -219,23 +240,26 @@ export const useValidacaoSequencialProcessos = () => {
// Processar componentes // Processar componentes
for (const componente of componentes) { for (const componente of componentes) {
const marcaComp = String(componente.marca_componente || '');
const qtdComp = Number(componente.quantidade_total || 0);
if (ordemAtual === 1) { if (ordemAtual === 1) {
// Primeiro processo: todos os componentes estão disponíveis // Primeiro processo: todos os componentes estão disponíveis
componentesDisponiveis.push({ componentesDisponiveis.push({
...componente, ...componente,
quantidade_disponivel: componente.quantidade_total || 0 quantidade_disponivel: qtdComp
}); });
} else { } else {
// Calcular quantidade disponível baseada no processo anterior // Calcular quantidade disponível baseada no processo anterior
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior( const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
componente.marca_componente, marcaComp,
ordemAtual - 1, ordemAtual - 1,
ofNumber, ofNumber,
'componente' 'componente'
); );
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada( const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
componente.marca_componente, marcaComp,
processoId, processoId,
ofNumber, ofNumber,
'componente' 'componente'
@@ -253,25 +277,7 @@ export const useValidacaoSequencialProcessos = () => {
} }
return { pecasDisponiveis, componentesDisponiveis }; return { pecasDisponiveis, componentesDisponiveis };
}, [processos, buscarQuantidadeProcessada]); }, [processos, buscarQuantidadeProcessada, calcularQuantidadeDisponivelProcessoAnterior]);
// 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
);
};
return { return {
historico, historico,
+19 -2
View File
@@ -120,22 +120,31 @@ export async function signIn(): Promise<void> {
window.location.assign(LOGTO_ENDPOINT + '/oidc/auth?' + params.toString()); window.location.assign(LOGTO_ENDPOINT + '/oidc/auth?' + params.toString());
} }
let callbackPromise: Promise<boolean> | null = null;
export async function handleCallback(): Promise<boolean> { export async function handleCallback(): Promise<boolean> {
if (callbackPromise) return callbackPromise;
callbackPromise = (async () => {
try { try {
const url = new URL(window.location.href); const url = new URL(window.location.href);
const code = url.searchParams.get('code'); const code = url.searchParams.get('code');
const state = url.searchParams.get('state'); const state = url.searchParams.get('state');
console.log('[Logto Callback] Recebido:', { hasCode: !!code, hasState: !!state });
if (!code) return false; if (!code) return false;
const expectedState = sessionStorage.getItem('logto_state'); 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) { if (state !== expectedState) {
console.error('Logto: state mismatch'); console.error('Logto: state mismatch');
return false; return false;
} }
const verifier = sessionStorage.getItem('logto_verifier') || '';
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
client_id: APP_ID, client_id: APP_ID,
@@ -180,7 +189,15 @@ export async function handleCallback(): Promise<boolean> {
} catch (err) { } catch (err) {
console.error('Callback error:', err); console.error('Callback error:', err);
return false; 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> { export async function getUser(): Promise<LogtoUser | null> {
+1 -1
View File
@@ -488,7 +488,7 @@ const CadastroOF = () => {
/> />
</div> </div>
<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 <Input
id="quantidade" id="quantidade"
type="number" type="number"
+1
View File
@@ -240,6 +240,7 @@ export default function CadastroPecasFiltrado() {
<CardContent> <CardContent>
<PecaForm <PecaForm
ofNumbers={[ofSelecionada]} // Apenas a OF selecionada ofNumbers={[ofSelecionada]} // Apenas a OF selecionada
ofDefault={ofSelecionada}
onSave={handleSave} onSave={handleSave}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onImportCSV={handleImportCSV} onImportCSV={handleImportCSV}
+1 -1
View File
@@ -24,7 +24,7 @@ const Callback = () => {
} else { } else {
setStatus('error'); setStatus('error');
setMessage('Falha no login. Tente novamente.'); setMessage('Falha no login. Tente novamente.');
setTimeout(() => navigate('/auth'), 2500); // Não navega automaticamente - deixa o user ver o erro
} }
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled) return;
+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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -373,6 +358,12 @@ const ConversoresDados = () => {
open={showGenericConverter} open={showGenericConverter}
onOpenChange={setShowGenericConverter} onOpenChange={setShowGenericConverter}
/> />
{/* Modal de Conversão Advance Steel (PDF para Excel) */}
<AdvanceSteelConverter
open={showAdvanceSteelConverter}
onOpenChange={setShowAdvanceSteelConverter}
/>
</div> </div>
); );
}; };
+11 -5
View File
@@ -14,7 +14,7 @@ import { CronogramaGantt } from '@/components/cronograma/CronogramaGantt';
import { CronogramaPDF } from '@/components/cronograma/CronogramaPDF'; import { CronogramaPDF } from '@/components/cronograma/CronogramaPDF';
import { useMobileResponsive } from '@/hooks/useMobileResponsive'; import { useMobileResponsive } from '@/hooks/useMobileResponsive';
import { usePermissionControl } from '@/hooks/usePermissionControl'; import { usePermissionControl } from '@/hooks/usePermissionControl';
import { CronogramaOf } from '@/types/cronograma'; import { CronogramaOf, ProcessoCronograma } from '@/types/cronograma';
const CronogramaOF = () => { const CronogramaOF = () => {
const { cronogramas, loading, loadCronogramas, deleteCronograma } = useCronogramas(); const { cronogramas, loading, loadCronogramas, deleteCronograma } = useCronogramas();
@@ -24,7 +24,7 @@ const CronogramaOF = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedOF, setSelectedOF] = useState<string>(''); const [selectedOF, setSelectedOF] = useState<string>('');
const [showCronogramaForm, setShowCronogramaForm] = useState(false); const [showCronogramaForm, setShowCronogramaForm] = useState(false);
const [selectedCronograma, setSelectedCronograma] = useState<any>(null); const [selectedCronograma, setSelectedCronograma] = useState<CronogramaOf | null>(null);
const [showGanttChart, setShowGanttChart] = useState(false); const [showGanttChart, setShowGanttChart] = useState(false);
const [showPDFGenerator, setShowPDFGenerator] = useState(false); const [showPDFGenerator, setShowPDFGenerator] = useState(false);
const [cronogramaForGantt, setCronogramaForGantt] = useState<CronogramaOf | null>(null); const [cronogramaForGantt, setCronogramaForGantt] = useState<CronogramaOf | null>(null);
@@ -42,7 +42,7 @@ const CronogramaOF = () => {
return ofA.localeCompare(ofB); return ofA.localeCompare(ofB);
}); });
const handleEdit = (cronograma: any) => { const handleEdit = (cronograma: CronogramaOf) => {
if (!canEdit()) return; if (!canEdit()) return;
setSelectedCronograma(cronograma); setSelectedCronograma(cronograma);
setShowCronogramaForm(true); setShowCronogramaForm(true);
@@ -68,6 +68,11 @@ const CronogramaOF = () => {
setSelectedCronograma(null); setSelectedCronograma(null);
}; };
const handleSaveSuccess = () => {
loadCronogramas();
setActiveTab('cronogramas');
};
const handleCloseGanttChart = () => { const handleCloseGanttChart = () => {
setShowGanttChart(false); setShowGanttChart(false);
setCronogramaForGantt(null); setCronogramaForGantt(null);
@@ -78,7 +83,7 @@ const CronogramaOF = () => {
setCronogramaForPDF(null); setCronogramaForPDF(null);
}; };
const MobileCronogramaCard = ({ cronograma }: { cronograma: any }) => ( const MobileCronogramaCard = ({ cronograma }: { cronograma: CronogramaOf }) => (
<Card className="w-full mb-4 bg-card border-border"> <Card className="w-full mb-4 bg-card border-border">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
@@ -120,7 +125,7 @@ const CronogramaOF = () => {
<div className="mt-1 text-card-foreground"> <div className="mt-1 text-card-foreground">
{Array.isArray(cronograma.processos) && cronograma.processos.length > 0 ? ( {Array.isArray(cronograma.processos) && cronograma.processos.length > 0 ? (
<div className="text-xs"> <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"> <div key={index} className="py-1 border-b border-border last:border-0">
{processo.nome_processo || `Processo ${index + 1}`} {processo.nome_processo || `Processo ${index + 1}`}
</div> </div>
@@ -285,6 +290,7 @@ const CronogramaOF = () => {
cronograma={selectedCronograma} cronograma={selectedCronograma}
onClose={handleCloseCronogramaForm} onClose={handleCloseCronogramaForm}
isOpen={showCronogramaForm} isOpen={showCronogramaForm}
onSaveSuccess={handleSaveSuccess}
/> />
)} )}
+112 -235
View File
@@ -24,7 +24,8 @@ const PrioridadesFabricacao = () => {
ofSelecionada, ofSelecionada,
faseSelecionada, faseSelecionada,
versaoAtual, versaoAtual,
onFiltroChange onFiltroChange,
incrementarRevisao
} = useItensPrioridadeFabricacaoFiltrado(); } = useItensPrioridadeFabricacaoFiltrado();
const [showPecaSelector, setShowPecaSelector] = useState(false); const [showPecaSelector, setShowPecaSelector] = useState(false);
@@ -58,17 +59,30 @@ const PrioridadesFabricacao = () => {
const numBigBoxes = Math.floor(quantity / 5); const numBigBoxes = Math.floor(quantity / 5);
const numSmallBoxes = quantity % 5; const numSmallBoxes = quantity % 5;
for (let i = 0; i < numBigBoxes; i++) { 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++) { 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 { } else {
for (let i = 0; i < quantity; i++) { 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 () => { const handleImprimirRelatorio = async () => {
@@ -77,6 +91,14 @@ const PrioridadesFabricacao = () => {
return; 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 { try {
// Criar nova janela para impressão // Criar nova janela para impressão
const printWindow = window.open('', '_blank'); const printWindow = window.open('', '_blank');
@@ -106,11 +128,11 @@ const PrioridadesFabricacao = () => {
const getCoresPrioridade = (codigo: string) => { const getCoresPrioridade = (codigo: string) => {
switch (codigo) { switch (codigo) {
case 'P1': return 'text-red-700 bg-red-100'; case 'P1': return 'text-red-700 bg-red-100 border-red-300';
case 'P2': return 'text-orange-700 bg-orange-100'; case 'P2': return 'text-orange-700 bg-orange-100 border-orange-300';
case 'P3': return 'text-blue-700 bg-blue-100'; case 'P3': return 'text-blue-700 bg-blue-100 border-blue-300';
case 'P4': return 'text-gray-700 bg-gray-200'; case 'P4': return 'text-gray-700 bg-gray-200 border-gray-300';
default: return 'text-gray-700 bg-gray-200'; default: return 'text-gray-700 bg-gray-200 border-gray-300';
} }
}; };
@@ -119,15 +141,15 @@ const PrioridadesFabricacao = () => {
itemsContent += ` itemsContent += `
<div class="priority-group ${pageBreakClass}"> <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)} ${getPrioridadeNome(codigo)}
</h2> </h2>
<div class="space-y-1"> <div class="space-y-1.5">
`; `;
// Gerar linhas de itens (3 por linha) // Gerar linhas de itens (3 por linha)
for (let i = 0; i < Math.ceil(itens.length / 3); i++) { 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); const rowItems = itens.slice(i * 3, (i + 1) * 3);
itemsContent += `<div class="grid grid-cols-3 gap-2 p-1 rounded-md ${bgColorClass}">`; itemsContent += `<div class="grid grid-cols-3 gap-2 p-1 rounded-md ${bgColorClass}">`;
@@ -141,13 +163,13 @@ const PrioridadesFabricacao = () => {
itemsContent += ` itemsContent += `
<div class="item-card"> <div class="item-card">
<div class="flex items-center flex-wrap gap-2 mb-2"> <div style="margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<span class="font-semibold text-sm whitespace-nowrap">${marca} (${quantidade})</span> <span style="font-size: 13px; font-weight: 700; color: #111827; vertical-align: middle; margin-right: 4px;">${marca} (${quantidade})</span>
<span class="text-xs font-medium text-gray-500">${infoType}</span> <span style="font-size: 11px; font-weight: 600; color: #6b7280; vertical-align: middle;">${infoType}</span>
${tickBoxes} ${tickBoxes}
</div> </div>
<div class="mt-2 text-xs"> <div class="item-signature">
<div class="border-b border-gray-400 pb-1 h-5">Data/Operador:</div> Data/Operador:
</div> </div>
</div> </div>
`; `;
@@ -176,20 +198,33 @@ const PrioridadesFabricacao = () => {
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checklist de Produção por Prioridade</title> <title>Checklist de Produção por Prioridade</title>
<script src="https://cdn.tailwindcss.com"></script> <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> <style>
body { body {
font-family: 'Inter', sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
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 { @media print {
body { body {
font-size: 9px; font-size: 9px;
} }
.check-box-print {
border: 1px solid #333 !important;
}
.page-break { .page-break {
page-break-before: always; page-break-before: always;
} }
@@ -200,85 +235,80 @@ const PrioridadesFabricacao = () => {
page-break-inside: avoid; 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> </style>
</head> </head>
<body class="bg-white"> <body class="bg-white">
<div class="max-w-4xl mx-auto p-6 sm:p-8"> <div class="max-w-4xl mx-auto p-6 sm:p-8">
<!-- Cabeçalho do Relatório --> <!-- 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> <div>
<h1 class="text-2xl font-bold text-gray-900">Checklist de Produção</h1> <h1 class="text-2xl font-bold text-gray-900 leading-tight">Checklist de Produção</h1>
<p class="text-gray-600">Formulário para apontamento da fabricação.</p> <p class="text-xs text-gray-600">Formulário para apontamento da fabricação.</p>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="font-semibold">Data de Emissão: <span class="font-normal">${dataAtual}</span> <p class="font-semibold text-sm">Data de Emissão: <span class="font-normal">${dataAtual}</span>
${versaoAtual ? `<span class="ml-2 text-gray-500">Rev. ${versaoAtual.revisao}</span>` : ''} <span class="ml-2 text-gray-500 font-medium">Rev. ${revisaoParaImprimir}</span>
</p> </p>
</div> </div>
</div> </div>
<!-- Informações da OF e Fase (Layout Melhorado) --> <!-- Informações da OF e Fase -->
<div class="border border-gray-200 bg-white p-4 rounded-lg mb-2"> <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-4"> <div class="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-3">
<!-- Coluna OF --> <!-- Coluna OF -->
<div> <div>
<p class="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p> <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> </div>
<!-- Coluna Fase --> <!-- Coluna Fase -->
<div> <div>
<p class="text-xs font-medium text-gray-500">Fase</p> <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> </div>
<!-- Coluna Processo --> <!-- Coluna Processo -->
<div class="md:col-span-2"> <div class="md:col-span-2">
<p class="text-xs font-medium text-gray-500">PROCESSO</p> <p class="text-xs font-medium text-gray-500 mb-1">PROCESSO</p>
<div class="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1"> <div style="margin-top: 2px;">
<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 style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
<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> <svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
<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> <rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
<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> </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>
</div> </div>
</div> </div>
<!-- Legenda --> <!-- Legenda -->
<div class="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3"> <div class="text-xs text-gray-600 mb-5 flex items-center flex-wrap">
<span class="font-semibold">Legenda:</span> <span class="font-semibold" style="margin-right: 6px;">Legenda:</span>
<span>Marca (Qtd)</span> <span style="margin-right: 6px;">Marca (Qtd)</span>
<span class="font-medium text-gray-500">(S/M)</span> <span class="font-medium text-gray-500" style="margin-right: 4px;">(S/M)</span>
<span>= Sem Montagem,</span> <span style="margin-right: 6px;">= Sem Montagem,</span>
<span class="font-medium text-gray-500">(C/M)</span> <span class="font-medium text-gray-500" style="margin-right: 4px;">(C/M)</span>
<span>= Com Montagem. Os quadrados</span> <span style="margin-right: 4px;">= Com Montagem. Os quadrados</span>
<div class="tick-box inline-block"></div> <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> <span>indicam o controle de peças fabricadas.</span>
</div> </div>
@@ -369,7 +399,12 @@ const PrioridadesFabricacao = () => {
<Button <Button
variant="outline" variant="outline"
size="sm" 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} disabled={!ofSelecionada || !faseSelecionada}
> >
<FileText className="h-4 w-4 mr-2" /> <FileText className="h-4 w-4 mr-2" />
@@ -420,168 +455,10 @@ const PrioridadesFabricacao = () => {
isOpen={showPrioridadesPDF} isOpen={showPrioridadesPDF}
onClose={() => setShowPrioridadesPDF(false)} onClose={() => setShowPrioridadesPDF(false)}
itensPorPrioridade={itensPorPrioridade} itensPorPrioridade={itensPorPrioridade}
ofSelecionada={ofSelecionada}
faseSelecionada={faseSelecionada}
versaoAtual={versaoAtual} 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> </div>
</StandardPageLayout> </StandardPageLayout>
); );
+84 -101
View File
@@ -2,75 +2,79 @@ import html2canvas from 'html2canvas';
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
export const generateProfessionalPDF = async (elementId: string, filename: string) => { export const generateProfessionalPDF = async (elementId: string, filename: string) => {
let tempContainer: HTMLElement | null = null;
try { try {
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
if (!element) { 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 // Criar um container temporário isolado fora de modais/dialogs para evitar bugs de tamanho 0x0
await new Promise(resolve => setTimeout(resolve, 200)); 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 // Clonar o elemento para o container isolado
const originalDisplay = element.style.display; const clonedElement = element.cloneNode(true) as HTMLElement;
const originalVisibility = element.style.visibility; clonedElement.style.display = 'block';
const originalPosition = element.style.position; 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'; tempContainer.appendChild(clonedElement);
element.style.visibility = 'visible'; document.body.appendChild(tempContainer);
element.style.position = 'relative';
// Forçar um reflow // Aguardar renderização e computação de layout
element.offsetHeight;
// Aguardar mais um pouco após forçar o reflow
await new Promise(resolve => setTimeout(resolve, 300)); await new Promise(resolve => setTimeout(resolve, 300));
console.log('Gerando PDF para elemento:', elementId); // Elemento a ser renderizado pelo html2canvas
console.log('Dimensões do elemento:', { const targetElement = (clonedElement.offsetHeight > 0 && clonedElement.offsetWidth > 0)
width: element.offsetWidth, ? clonedElement
height: element.offsetHeight, : element;
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');
}
// Configurações otimizadas para html2canvas // Configurações otimizadas para html2canvas
const canvas = await html2canvas(element, { const canvas = await html2canvas(targetElement, {
scale: 2, scale: 2,
useCORS: true, useCORS: true,
allowTaint: false, allowTaint: true,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
width: element.scrollWidth, logging: false,
height: element.scrollHeight, windowWidth: 1024,
scrollX: 0, onclone: (clonedDoc) => {
scrollY: 0, const found = clonedDoc.getElementById(elementId) || clonedDoc.body;
windowWidth: Math.max(element.scrollWidth, 1200), if (found) {
windowHeight: Math.max(element.scrollHeight, 800), (found as HTMLElement).style.display = 'block';
foreignObjectRendering: false, (found as HTMLElement).style.visibility = 'visible';
removeContainer: false, }
imageTimeout: 10000, }
logging: false
}); });
console.log('Canvas criado com sucesso:', { if (!canvas || canvas.width === 0 || canvas.height === 0) {
width: canvas.width, throw new Error('Não foi possível capturar o layout visual para o PDF');
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');
} }
// Restaurar estilos originais const imgData = canvas.toDataURL('image/png', 1.0);
element.style.display = originalDisplay; if (!imgData || !imgData.startsWith('data:image/png;base64,')) {
element.style.visibility = originalVisibility; throw new Error('Falha ao processar os dados da imagem para o PDF');
element.style.position = originalPosition; }
// Criar PDF com configurações otimizadas // Criar PDF com configurações otimizadas
const pdf = new jsPDF({ const pdf = new jsPDF({
@@ -85,10 +89,10 @@ export const generateProfessionalPDF = async (elementId: string, filename: strin
const pageHeight = 297; const pageHeight = 297;
// Margens adequadas // Margens adequadas
const marginTop = 15; const marginTop = 12;
const marginBottom = 15; const marginBottom = 12;
const marginLeft = 15; const marginLeft = 12;
const marginRight = 15; const marginRight = 12;
// Área útil para conteúdo // Área útil para conteúdo
const contentWidth = pageWidth - marginLeft - marginRight; const contentWidth = pageWidth - marginLeft - marginRight;
@@ -98,103 +102,82 @@ export const generateProfessionalPDF = async (elementId: string, filename: strin
const imgWidth = contentWidth; const imgWidth = contentWidth;
const imgHeight = (canvas.height * contentWidth) / canvas.width; 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 // Sistema de paginação
let currentY = 0; let currentY = 0;
let pageNumber = 1; 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 // Função para adicionar rodapé com numeração
const addFooter = (pageNum: number, totalPages: number) => { const addFooter = (pageNum: number, totalPagesCount: number) => {
pdf.setFontSize(8); pdf.setFontSize(8);
pdf.setTextColor(100, 100, 100); pdf.setTextColor(120, 120, 120);
const footerText = `Página ${pageNum} de ${totalPages}`; const footerText = `Página ${pageNum} de ${totalPagesCount}`;
const textWidth = pdf.getTextWidth(footerText); const textWidth = pdf.getTextWidth(footerText);
const footerX = (pageWidth - textWidth) / 2; const footerX = (pageWidth - textWidth) / 2;
const footerY = pageHeight - 8; const footerY = pageHeight - 6;
pdf.text(footerText, footerX, footerY); pdf.text(footerText, footerX, footerY);
}; };
// Primeira página
if (imgHeight <= contentHeight) { if (imgHeight <= contentHeight) {
// Conteúdo cabe em uma página // 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); addFooter(1, 1);
} else { } else {
// Conteúdo precisa de múltiplas páginas // Conteúdo precisa de múltiplas páginas com corte preciso
while (currentY < imgHeight) { while (currentY < imgHeight) {
if (pageNumber > 1) { if (pageNumber > 1) {
pdf.addPage(); pdf.addPage();
} }
// Calcular a altura restante do conteúdo
const remainingHeight = imgHeight - currentY; const remainingHeight = imgHeight - currentY;
const currentPageHeight = Math.min(contentHeight, remainingHeight); const currentPageHeight = Math.min(contentHeight, remainingHeight);
// Criar um canvas temporário para a seção atual
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) { if (tempCtx && currentPageHeight > 0) {
const sliceHeight = Math.round((currentPageHeight * canvas.width) / imgWidth);
tempCanvas.width = canvas.width; 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( tempCtx.drawImage(
canvas, canvas,
0, 0,
(currentY * canvas.width) / imgWidth, Math.round((currentY * canvas.width) / imgWidth),
canvas.width, canvas.width,
tempCanvas.height, sliceHeight,
0, 0,
0, 0,
canvas.width, canvas.width,
tempCanvas.height sliceHeight
); );
// Converter para dados de imagem
const tempImgData = tempCanvas.toDataURL('image/png', 1.0); const tempImgData = tempCanvas.toDataURL('image/png', 1.0);
if (tempImgData && tempImgData.startsWith('data:image/png;base64,')) {
// Adicionar a seção ao PDF pdf.addImage(tempImgData, 'PNG', marginLeft, marginTop, imgWidth, currentPageHeight, undefined, 'FAST');
pdf.addImage(tempImgData, 'PNG', marginLeft, marginTop, imgWidth, currentPageHeight); }
} }
// Adicionar rodapé
addFooter(pageNumber, totalPages); addFooter(pageNumber, totalPages);
// Preparar para próxima página
currentY += contentHeight; currentY += contentHeight;
pageNumber++; pageNumber++;
} }
} }
// Forçar o download do PDF // Salvar arquivo PDF
console.log('Iniciando download do PDF:', filename);
pdf.save(filename); 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; return true;
} catch (error) { } catch (error: any) {
console.error('Erro detalhado ao gerar PDF:', error); 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();
+32
View File
@@ -0,0 +1,32 @@
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_PUBLISHABLE_KEY=\"(.*?)\"/)[1];
const supabase = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
async function run() {
const pecasParaInserir = [{
of_number: 'B133',
etapa_fase: '1',
marca: 'M2',
descricao: 'Desc M2',
quantidade: 1,
peso_unitario: 10.5,
peso_total: 10.5,
tratamento_superficial: 'pintura',
material: 'A36',
perfil_principal: 'C',
tem_componentes: false,
user_id: '00000000-0000-0000-0000-000000000000',
prioridade: 'P4'
}];
const { data, error } = await supabase
.from('pecas')
.insert(pecasParaInserir)
.select('id, of_number, etapa_fase, marca');
console.log('Result:', JSON.stringify({ data, error }, null, 2));
}
run();
+32
View File
@@ -0,0 +1,32 @@
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_PUBLISHABLE_KEY=\"(.*?)\"/)[1];
const supabase = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
async function run() {
const pecasParaInserir = [{
of_number: 'B133',
etapa_fase: '1',
marca: 'M1',
descricao: 'Desc M1',
quantidade: 1,
peso_unitario: 10,
peso_total: 10,
tratamento_superficial: 'pintura',
material: 'A36',
perfil_principal: 'C',
tem_componentes: false,
user_id: '00000000-0000-0000-0000-000000000000', // invalid uuid? maybe this fails?
prioridade: 'P4'
}];
const { data, error } = await supabase
.from('pecas')
.insert(pecasParaInserir)
.select('id, of_number, etapa_fase, marca');
console.log('Result:', JSON.stringify({ data, error }, null, 2));
}
run();
+25
View File
@@ -0,0 +1,25 @@
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_PUBLISHABLE_KEY=\"(.*?)\"/)[1];
const supabase = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
async function run() {
const { data: existingFicha } = await supabase
.from('ficha_tecnica_contratos')
.select('id')
.eq('of_number', 'B133')
.maybeSingle();
if (existingFicha) {
const { data, error } = await supabase
.from('ficha_tecnica_contratos')
.update({ quantidade: 2 })
.eq('id', existingFicha.id);
console.log('Update result:', { data, error });
} else {
console.log('No ficha found for B133');
}
}
run();
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# ---------------------------------------------------------
# TRACKSTEEL APP: DEPLOY E SINCRONIZAÇÃO
# ---------------------------------------------------------
CYAN='\033[0;36m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "\n${CYAN}🚀 Iniciando Ciclo Automático de Deploy TrackSteelAPP...${NC}"
echo -e "${YELLOW}📝 Sincronizando código...${NC}"
git add .
if git diff-index --quiet HEAD --; then
echo -e "${GREEN}✨ Nenhuma nova alteração para comitar.${NC}"
else
TIMESTAMP=$(date +"%d/%m/%Y %H:%M:%S")
MSG="${1:-🚀 Auto-deploy: TrackSteelAPP atualizado em $TIMESTAMP}"
echo -e "${CYAN}📤 Criando novo commit: $MSG${NC}"
git commit -m "$MSG"
fi
echo -e "${CYAN}📤 Sincronizando com o repositório remoto (git push)...${NC}"
git push
echo -e "${YELLOW}🔄 Disparando Deploy via API Oficial no Coolify...${NC}"
COOLIFY_TOKEN="19|ZJKUbBgyeNGcKrVkMI5gJ1uMHDC39d9VIShHL473a60d9882"
TRACKSTEEL_UUID="i8o44gggg00o88ccc8oo48kk"
curl -s -X GET "https://painel.reifonas.cloud/api/v1/deploy?uuid=${TRACKSTEEL_UUID}&force=false" \
-H "Authorization: Bearer $COOLIFY_TOKEN"
echo -e "\n${GREEN}🚀 Deploy oficial via API engatilhado com sucesso!${NC}"
echo -e "${GREEN}🏁 Ciclo concluído com sucesso.${NC}\n"
+144
View File
@@ -0,0 +1,144 @@
BEGIN;
-- Drop existing policies
DROP POLICY IF EXISTS "Users with permissions can view fichas" ON "TS_ERP".ficha_tecnica_contratos;
DROP POLICY IF EXISTS "Users with permissions can create fichas" ON "TS_ERP".ficha_tecnica_contratos;
DROP POLICY IF EXISTS "Users with permissions can update fichas" ON "TS_ERP".ficha_tecnica_contratos;
DROP POLICY IF EXISTS "Users with permissions can delete fichas" ON "TS_ERP".ficha_tecnica_contratos;
-- Function for view access (can_view_only or better)
CREATE OR REPLACE FUNCTION "TS_ERP".user_can_view_ofs(_user_id uuid)
RETURNS boolean
LANGUAGE plpgsql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
user_is_admin boolean := false;
user_permissions record;
BEGIN
-- Admin bypass
SELECT EXISTS (
SELECT 1 FROM "TS_ERP".user_roles
WHERE user_id = _user_id AND role = 'admin'
) INTO user_is_admin;
IF user_is_admin THEN RETURN true; END IF;
SELECT p.permissions INTO user_permissions
FROM "TS_ERP".profiles pr
JOIN "TS_ERP".privileges p ON pr.privilege_id = p.id
WHERE pr.id = _user_id;
IF user_permissions.permissions IS NOT NULL THEN
-- If they have ANY of these, they can view things
IF (user_permissions.permissions->>'can_admin')::boolean = true OR
(user_permissions.permissions->>'can_create_update_delete')::boolean = true OR
(user_permissions.permissions->>'can_create_only')::boolean = true OR
(user_permissions.permissions->>'can_view_only')::boolean = true THEN
RETURN true;
END IF;
END IF;
RETURN false;
END;
$$;
-- Function for write access (can_create_only or better)
CREATE OR REPLACE FUNCTION "TS_ERP".user_can_write_ofs(_user_id uuid)
RETURNS boolean
LANGUAGE plpgsql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
user_is_admin boolean := false;
user_permissions record;
BEGIN
-- Admin bypass
SELECT EXISTS (
SELECT 1 FROM "TS_ERP".user_roles
WHERE user_id = _user_id AND role = 'admin'
) INTO user_is_admin;
IF user_is_admin THEN RETURN true; END IF;
SELECT p.permissions INTO user_permissions
FROM "TS_ERP".profiles pr
JOIN "TS_ERP".privileges p ON pr.privilege_id = p.id
WHERE pr.id = _user_id;
IF user_permissions.permissions IS NOT NULL THEN
-- They need these to write
IF (user_permissions.permissions->>'can_admin')::boolean = true OR
(user_permissions.permissions->>'can_create_update_delete')::boolean = true OR
(user_permissions.permissions->>'can_create_only')::boolean = true THEN
RETURN true;
END IF;
END IF;
RETURN false;
END;
$$;
-- Function for full edit/delete (can_create_update_delete or better)
CREATE OR REPLACE FUNCTION "TS_ERP".user_can_edit_delete_ofs(_user_id uuid)
RETURNS boolean
LANGUAGE plpgsql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
user_is_admin boolean := false;
user_permissions record;
BEGIN
-- Admin bypass
SELECT EXISTS (
SELECT 1 FROM "TS_ERP".user_roles
WHERE user_id = _user_id AND role = 'admin'
) INTO user_is_admin;
IF user_is_admin THEN RETURN true; END IF;
SELECT p.permissions INTO user_permissions
FROM "TS_ERP".profiles pr
JOIN "TS_ERP".privileges p ON pr.privilege_id = p.id
WHERE pr.id = _user_id;
IF user_permissions.permissions IS NOT NULL THEN
-- They need these to edit or delete
IF (user_permissions.permissions->>'can_admin')::boolean = true OR
(user_permissions.permissions->>'can_create_update_delete')::boolean = true THEN
RETURN true;
END IF;
END IF;
RETURN false;
END;
$$;
-- Create policies with new granular rules
CREATE POLICY "Users can view fichas if they have view privileges"
ON "TS_ERP".ficha_tecnica_contratos FOR SELECT
USING ("TS_ERP".user_can_view_ofs(auth.uid()));
CREATE POLICY "Users can create fichas if they have write privileges"
ON "TS_ERP".ficha_tecnica_contratos FOR INSERT
WITH CHECK ("TS_ERP".user_can_write_ofs(auth.uid()));
-- For updates, we let full editors update, or the creator themselves if they retain write permissions
CREATE POLICY "Users can update fichas"
ON "TS_ERP".ficha_tecnica_contratos FOR UPDATE
USING (
"TS_ERP".user_can_edit_delete_ofs(auth.uid()) OR
(auth.uid() = user_id AND "TS_ERP".user_can_write_ofs(auth.uid()))
);
CREATE POLICY "Users can delete fichas"
ON "TS_ERP".ficha_tecnica_contratos FOR DELETE
USING (
"TS_ERP".user_can_edit_delete_ofs(auth.uid()) OR
(auth.uid() = user_id AND "TS_ERP".user_can_write_ofs(auth.uid()))
);
COMMIT;