Compare commits
99 Commits
a7bb224a48
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc4ac64f55 | |||
| 4fe116a9c0 | |||
| cf0c6cd657 | |||
| d5fe88520b | |||
| 5d3879c0da | |||
| 5316fc9551 | |||
| 1578fb23e2 | |||
| 9fb29d55a8 | |||
| b201e67291 | |||
| 78d3f72545 | |||
| 8413cf64a9 | |||
| f27610df97 | |||
| 8107e46eb1 | |||
| 9109c4ca04 | |||
| 35e9387be3 | |||
| ed60dcb12c | |||
| 01f509d88b | |||
| 1616baaafc | |||
| 3cdbd3d747 | |||
| 79c4c6c460 | |||
| fa3408c238 | |||
| dccb9acdce | |||
| 8478eecb93 | |||
| 22892a4374 | |||
| d57c94b9a5 | |||
| 52b4ca9133 | |||
| 0745822069 | |||
| 6b4856ae06 | |||
| 09f95fa80c | |||
| 496b73fbc4 | |||
| c869cc2e3d | |||
| ee688b0f5d | |||
| 36d545d36d | |||
| 046639321b | |||
| 4504c1ded2 | |||
| f72b7c86fd | |||
| 8bd95af40a | |||
| 72bf7b0725 | |||
| 4254566b36 | |||
| 9f4873d93d | |||
| 84aa8a32f6 | |||
| 48ac114b74 | |||
| 30f27f705a | |||
| ecb6d4b2c4 | |||
| f2fd8bd699 | |||
| 2c20ae7e3c | |||
| 3d8c38f632 | |||
| 9d999fe0a5 | |||
| 0a274502e7 | |||
| b3cebfa803 | |||
| c6bbb6dd84 | |||
| 1a1465f471 | |||
| ba9620250b | |||
| 807e31f55a | |||
| 5e5ca2386f | |||
| db1ffd9e87 | |||
| 887e619f2e | |||
| 3da77e8d7a | |||
| 842b826463 | |||
| be5e86773b | |||
| 1da0651a88 | |||
| 89992195c0 | |||
| 10c67672f8 | |||
| 2086c63030 | |||
| 1fa393c157 | |||
| 33e95f125b | |||
| a4ea6787b8 | |||
| dd7d1c41dd | |||
| 50d563c0fd | |||
| 08b782a488 | |||
| 337b397772 | |||
| 59ee8f56a8 | |||
| 88e467c02b | |||
| b845623714 | |||
| 397d2e1e34 | |||
| 0ce317a8f3 | |||
| a3c76c17b4 | |||
| 3b84c026d5 | |||
| 278f0f4491 | |||
| 92625897bf | |||
| f28e410aad | |||
| af40f707bb | |||
| a5968c76f1 | |||
| 0487439c2a | |||
| 6826d1d7d3 | |||
| 2a1b0b024a | |||
| b4dae471ad | |||
| 49ddc7523a | |||
| 69a8b4d1e0 | |||
| 9fae693cb3 | |||
| ad00e3b65d | |||
| 938f22a423 | |||
| 65ac295a72 | |||
| 216225be72 | |||
| 7f810f2b77 | |||
| dc20985524 | |||
| 98b8c009ed | |||
| d88759ed08 | |||
| 4218d9090f |
@@ -21,10 +21,18 @@ COPY . .
|
||||
ARG VITE_SUPABASE_URL
|
||||
ARG VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
ARG VITE_SUPABASE_PROJECT_ID
|
||||
ARG VITE_LOGTO_ENDPOINT
|
||||
ARG VITE_LOGTO_APP_ID
|
||||
ARG VITE_LOGTO_REDIRECT_URI
|
||||
ARG VITE_LOGTO_POST_LOGOUT_REDIRECT_URI
|
||||
|
||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||
ENV VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
ENV VITE_SUPABASE_PROJECT_ID=$VITE_SUPABASE_PROJECT_ID
|
||||
ENV VITE_LOGTO_ENDPOINT=$VITE_LOGTO_ENDPOINT
|
||||
ENV VITE_LOGTO_APP_ID=$VITE_LOGTO_APP_ID
|
||||
ENV VITE_LOGTO_REDIRECT_URI=$VITE_LOGTO_REDIRECT_URI
|
||||
ENV VITE_LOGTO_POST_LOGOUT_REDIRECT_URI=$VITE_LOGTO_POST_LOGOUT_REDIRECT_URI
|
||||
|
||||
# Build de produção
|
||||
RUN npm run build
|
||||
|
||||
@@ -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>
|
||||
@@ -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$;
|
||||
@@ -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.
|
||||
@@ -50,6 +50,7 @@ server {
|
||||
# Qualquer rota que não corresponda a um arquivo redireciona pro index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
}
|
||||
|
||||
# ---- Bloquear acesso a arquivos sensíveis ----
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = 'https://supabase.reifonas.cloud';
|
||||
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
|
||||
|
||||
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
|
||||
const sbPub = createClient(supabaseUrl, supabaseKey, { db: { schema: 'public' } });
|
||||
|
||||
async function run() {
|
||||
let resERP = await sbERP.from('ordens_fabricacao').select('status, num_of');
|
||||
let resPub = await sbPub.from('ordens_fabricacao').select('status, num_of');
|
||||
|
||||
console.log('--- TS_ERP ---');
|
||||
if (resERP.data) {
|
||||
console.log(resERP.data);
|
||||
} else {
|
||||
console.log('no data', resERP.error);
|
||||
}
|
||||
console.log('--- PUBLIC ---');
|
||||
if (resPub.data) {
|
||||
console.log(resPub.data);
|
||||
} else {
|
||||
console.log('no data', resPub.error);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = 'https://supabase.reifonas.cloud';
|
||||
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
|
||||
|
||||
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
|
||||
|
||||
async function run() {
|
||||
const tables = ['ordens_fabricacao', 'pecas', 'apontamentos_producao'];
|
||||
for (let table of tables) {
|
||||
let res = await sbERP.from(table).select('*', { count: 'exact', head: true });
|
||||
console.log(`TS_ERP.${table}:`, res.count, res.error ? res.error.message : 'OK');
|
||||
}
|
||||
}
|
||||
run();
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = 'https://supabase.reifonas.cloud';
|
||||
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
|
||||
|
||||
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
|
||||
const sbPub = createClient(supabaseUrl, supabaseKey, { db: { schema: 'public' } });
|
||||
|
||||
async function del(table, col, val) {
|
||||
if (!val) return;
|
||||
let res = await sbERP.from(table).delete().eq(col, val);
|
||||
if (res.error && res.error.message.includes("does not exist")) {
|
||||
res = await sbPub.from(table).delete().eq(col, val);
|
||||
}
|
||||
}
|
||||
|
||||
async function delIn(table, col, vals) {
|
||||
if (!vals || vals.length === 0) return;
|
||||
let res = await sbERP.from(table).delete().in(col, vals);
|
||||
if (res.error && res.error.message.includes("does not exist")) {
|
||||
res = await sbPub.from(table).delete().in(col, vals);
|
||||
}
|
||||
}
|
||||
|
||||
async function getOF(ofNumber) {
|
||||
let res = await sbERP.from('ordens_fabricacao').select('id').eq('num_of', ofNumber).single();
|
||||
if (res.error) res = await sbPub.from('ordens_fabricacao').select('id').eq('num_of', ofNumber).single();
|
||||
if (res.error) res = await sbERP.from('ofs_concluidas').select('id').eq('of_number', ofNumber).single();
|
||||
return res.data ? res.data.id : null;
|
||||
}
|
||||
|
||||
async function getPecas(ofNumber) {
|
||||
let res = await sbERP.from('pecas').select('id').eq('of_number', ofNumber);
|
||||
if (res.error) res = await sbPub.from('pecas').select('id').eq('of_number', ofNumber);
|
||||
return res.data ? res.data.map(p => p.id) : [];
|
||||
}
|
||||
|
||||
async function deleteOF(ofNumber) {
|
||||
console.log(`\nIniciando EXCLUSÃO TOTAL da OF: ${ofNumber}`);
|
||||
const ofId = await getOF(ofNumber);
|
||||
const pecaIds = await getPecas(ofNumber);
|
||||
|
||||
if (pecaIds.length > 0) {
|
||||
await delIn('itens_romaneio_pecas', 'peca_id', pecaIds);
|
||||
await delIn('itens_prioridade_fabricacao', 'peca_id', pecaIds);
|
||||
await delIn('processos_pecas_datas', 'peca_id', pecaIds);
|
||||
}
|
||||
|
||||
await del('logs_apontamentos_automaticos', 'of_number', ofNumber);
|
||||
await del('romaneios_expedicao', 'of_number', ofNumber);
|
||||
await del('apontamentos_producao', 'of_number', ofNumber);
|
||||
await del('apontamentos_diario_recursos', 'of_number', ofNumber);
|
||||
await del('prioridades_fabricacao', 'of_number', ofNumber);
|
||||
|
||||
if (ofId) await del('cronogramas_of', 'of_id', ofId);
|
||||
await del('pecas', 'of_number', ofNumber);
|
||||
await del('ordens_fabricacao', 'num_of', ofNumber);
|
||||
await del('ofs_concluidas', 'of_number', ofNumber);
|
||||
|
||||
console.log(`✅ Fim da rotina para a OF ${ofNumber}.`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let res = await sbERP.from('ofs_concluidas').select('of_number');
|
||||
if (res.error) {
|
||||
console.log("TS_ERP failed, trying public...");
|
||||
res = await sbPub.from('ofs_concluidas').select('of_number');
|
||||
}
|
||||
if (res.error) {
|
||||
console.error("Erro ao buscar ofs concluidas:", res.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const ofs = res.data.map(r => r.of_number).filter(v => v);
|
||||
console.log("OFs concluídas encontradas para deletar:", ofs);
|
||||
|
||||
for (const of of ofs) {
|
||||
await deleteOF(of);
|
||||
}
|
||||
}
|
||||
run();
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = 'https://supabase.reifonas.cloud';
|
||||
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
|
||||
|
||||
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
|
||||
const sbPub = createClient(supabaseUrl, supabaseKey, { db: { schema: 'public' } });
|
||||
|
||||
async function del(table, col, val) {
|
||||
if (!val) return;
|
||||
let res = await sbERP.from(table).delete().eq(col, val);
|
||||
if (res.error && res.error.message.includes("does not exist")) {
|
||||
res = await sbPub.from(table).delete().eq(col, val);
|
||||
}
|
||||
if (res.error) console.log(` ❌ Erro em ${table}: ${res.error.message}`);
|
||||
else console.log(` ✅ ${table} limpo.`);
|
||||
}
|
||||
|
||||
async function delIn(table, col, vals) {
|
||||
if (!vals || vals.length === 0) return;
|
||||
let res = await sbERP.from(table).delete().in(col, vals);
|
||||
if (res.error && res.error.message.includes("does not exist")) {
|
||||
res = await sbPub.from(table).delete().in(col, vals);
|
||||
}
|
||||
if (res.error) console.log(` ❌ Erro em ${table}: ${res.error.message}`);
|
||||
else console.log(` ✅ ${table} limpo.`);
|
||||
}
|
||||
|
||||
async function getOF(ofNumber) {
|
||||
let res = await sbERP.from('ordens_fabricacao').select('id').eq('num_of', ofNumber).single();
|
||||
if (res.error) {
|
||||
res = await sbPub.from('ordens_fabricacao').select('id').eq('num_of', ofNumber).single();
|
||||
}
|
||||
return res.data ? res.data.id : null;
|
||||
}
|
||||
|
||||
async function getPecas(ofNumber) {
|
||||
let res = await sbERP.from('pecas').select('id').eq('of_number', ofNumber);
|
||||
if (res.error) {
|
||||
res = await sbPub.from('pecas').select('id').eq('of_number', ofNumber);
|
||||
}
|
||||
return res.data ? res.data.map(p => p.id) : [];
|
||||
}
|
||||
|
||||
async function deleteOF(ofNumber) {
|
||||
console.log(`\nIniciando EXCLUSÃO TOTAL da OF: ${ofNumber}`);
|
||||
|
||||
const ofId = await getOF(ofNumber);
|
||||
const pecaIds = await getPecas(ofNumber);
|
||||
|
||||
console.log(` - ID da OF encontrado: ${ofId || 'Nenhum (já excluída?)'}`);
|
||||
console.log(` - Total de peças encontradas: ${pecaIds.length}`);
|
||||
|
||||
// 1. Limpar dependências de peças
|
||||
if (pecaIds.length > 0) {
|
||||
await delIn('itens_romaneio_pecas', 'peca_id', pecaIds);
|
||||
await delIn('itens_prioridade_fabricacao', 'peca_id', pecaIds);
|
||||
await delIn('processos_pecas_datas', 'peca_id', pecaIds);
|
||||
}
|
||||
|
||||
// 2. Limpar dependências diretas de of_number
|
||||
await del('logs_apontamentos_automaticos', 'of_number', ofNumber);
|
||||
await del('romaneios_expedicao', 'of_number', ofNumber);
|
||||
await del('apontamentos_producao', 'of_number', ofNumber);
|
||||
await del('apontamentos_diario_recursos', 'of_number', ofNumber);
|
||||
await del('prioridades_fabricacao', 'of_number', ofNumber);
|
||||
|
||||
// 3. Limpar cronogramas
|
||||
if (ofId) {
|
||||
await del('cronogramas_of', 'of_id', ofId);
|
||||
}
|
||||
|
||||
// 4. Excluir Peças
|
||||
await del('pecas', 'of_number', ofNumber);
|
||||
|
||||
// 5. Excluir OF
|
||||
await del('ordens_fabricacao', 'num_of', ofNumber);
|
||||
|
||||
console.log(`✅ Fim da rotina para a OF ${ofNumber}.\n`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const ofsToTest = ['B119', 'B122', 'B110'];
|
||||
for (const of of ofsToTest) {
|
||||
await deleteOF(of);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = 'https://supabase.reifonas.cloud';
|
||||
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoic2VydmljZV9yb2xlIn0._n2Kj2f29z1u0pOYUGqAr-1Xjt-xQpK9KDhhhGvOIro';
|
||||
|
||||
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
|
||||
|
||||
async function run() {
|
||||
let { data, error, count } = await sbERP.from('ordens_fabricacao').select('*', { count: 'exact' });
|
||||
console.log('All OFs in TS_ERP:', count, error ? error : `First row: ${JSON.stringify(data[0])}`);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = 'https://supabase.reifonas.cloud';
|
||||
const supabaseKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc3Mjk5NTUwMCwiZXhwIjo0OTI4NjY5MTAwLCJyb2xlIjoiYW5vbiJ9.kOAYmQJlNd3LsssUHaNyvWZpa2sunfpLj24F_X-PRNY';
|
||||
|
||||
const sbERP = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
|
||||
|
||||
async function run() {
|
||||
const { data, error } = await sbERP.from('ordens_fabricacao').select('id, num_of, status');
|
||||
console.log('--- anon key ---');
|
||||
console.log(data);
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -1,25 +1,44 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
|
||||
import { useUserProfile } from '@/hooks/useUserProfile';
|
||||
|
||||
interface ProtectedAdminRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||
const { profile, loading: profileLoading } = useUserProfile();
|
||||
|
||||
if (loading || roleLoading) {
|
||||
return <div>Carregando...</div>;
|
||||
const loading = authLoading || roleLoading || profileLoading;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-muted-foreground">Carregando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/auth" replace />;
|
||||
}
|
||||
|
||||
// Verificar se o usuário está ativo no sistema
|
||||
if (profile && profile.status !== 'active') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
|
||||
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
|
||||
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
@@ -1,44 +1,20 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { useUserProfile } from '@/hooks/useUserProfile';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { profile, loading: profileLoading } = useUserProfile();
|
||||
|
||||
// Buscar o perfil do usuário para verificar o status
|
||||
const { data: profile, isLoading: profileLoading, error } = useQuery({
|
||||
queryKey: ['user-profile', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!user?.id) return null;
|
||||
const loading = authLoading || profileLoading;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('status')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao verificar perfil do usuário:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
enabled: !!user?.id,
|
||||
retry: 1, // Limitar tentativas de retry
|
||||
staleTime: 30000, // Cache por 30 segundos
|
||||
});
|
||||
|
||||
// Mostrar loading enquanto carrega autenticação ou perfil
|
||||
if (loading || (user && profileLoading)) {
|
||||
// Mostrar loading enquanto carrega autenticação
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-muted-foreground">Carregando...</div>
|
||||
@@ -48,54 +24,19 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
|
||||
// Se não há usuário, redirecionar para auth
|
||||
if (!user) {
|
||||
logger.debug('ProtectedRoute: Usuário não autenticado, redirecionando para /auth');
|
||||
return <Navigate to="/auth" replace />;
|
||||
}
|
||||
|
||||
// Se há erro ao carregar perfil, permitir acesso (para evitar loop)
|
||||
if (error) {
|
||||
logger.warn('ProtectedRoute: Erro ao carregar perfil, permitindo acesso');
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Se há usuário mas não conseguiu carregar o perfil ainda, mostrar loading
|
||||
if (!profile && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-muted-foreground">Verificando permissões...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se o usuário tem status 'active'
|
||||
// Verificar se o usuário está ativo no sistema
|
||||
if (profile && profile.status !== 'active') {
|
||||
logger.debug('ProtectedRoute: Usuário com status inválido', profile.status as any);
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-center space-y-4 p-8 max-w-md mx-auto">
|
||||
<div className="text-6xl">⏳</div>
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Aguardando Aprovação
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Sua conta foi criada com sucesso, mas ainda precisa ser aprovada por um administrador.
|
||||
Você receberá acesso assim que sua solicitação for analisada.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
supabase.auth.signOut();
|
||||
}}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Fazer Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
|
||||
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
|
||||
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug('ProtectedRoute: Usuário ativo autorizado, renderizando conteúdo');
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserPermissions } from '@/hooks/useUserPermissions';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
import { useUserProfile } from '@/hooks/useUserProfile';
|
||||
|
||||
interface ProtectedRouteByResourceProps {
|
||||
children: React.ReactNode;
|
||||
@@ -13,30 +12,14 @@ interface ProtectedRouteByResourceProps {
|
||||
|
||||
export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> = ({
|
||||
children,
|
||||
resourceKey
|
||||
}) => {
|
||||
const { user, loading } = useAuth();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||
const { profile, loading: profileLoading } = useUserProfile();
|
||||
|
||||
// Use a try-catch to prevent the hook from crashing the component
|
||||
let permissionsData;
|
||||
try {
|
||||
permissionsData = useUserPermissions();
|
||||
} catch (error) {
|
||||
console.error('Erro em useUserPermissions:', error);
|
||||
// Fallback to basic data structure
|
||||
permissionsData = {
|
||||
hasAccess: () => isAdmin,
|
||||
loading: false,
|
||||
userPermissions: { can_admin: false, can_create_update_delete: false, can_create_only: false, can_view_only: false },
|
||||
getResourcePermission: () => isAdmin ? 'can_admin' : 'no_access'
|
||||
};
|
||||
}
|
||||
const loading = authLoading || roleLoading || profileLoading;
|
||||
|
||||
const { hasAccess, loading: permissionsLoading, userPermissions, getResourcePermission } = permissionsData;
|
||||
|
||||
// Aguardar carregamento
|
||||
if (loading || permissionsLoading || roleLoading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-muted-foreground">Carregando...</div>
|
||||
@@ -44,87 +27,22 @@ export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> =
|
||||
);
|
||||
}
|
||||
|
||||
// Redirecionar para login se não autenticado
|
||||
if (!user) {
|
||||
return <Navigate to="/auth" replace />;
|
||||
}
|
||||
|
||||
// Admin sempre tem acesso (exceto se explicitamente negado)
|
||||
if (isAdmin) {
|
||||
const resourcePermission = getResourcePermission(resourceKey);
|
||||
// Se admin tem negação explícita, negar acesso
|
||||
if (resourcePermission === 'no_access') {
|
||||
if (import.meta.env.DEV) {
|
||||
logger.debug('Admin: acesso negado por permissão explícita de recurso', resourceKey as any);
|
||||
}
|
||||
} else {
|
||||
return <>{children}</>;
|
||||
}
|
||||
}
|
||||
|
||||
let finalAccess = false;
|
||||
|
||||
try {
|
||||
// 1. PRIMEIRO: Verificar permissão específica do recurso
|
||||
const resourcePermission = getResourcePermission(resourceKey);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
logger.debug('Verificando acesso ao recurso', {
|
||||
resourceKey,
|
||||
user: user?.email,
|
||||
isAdmin,
|
||||
resourcePermission,
|
||||
userPermissions
|
||||
} as any);
|
||||
}
|
||||
|
||||
// 2. Se há permissão específica definida, ela prevalece SEMPRE
|
||||
if (resourcePermission !== 'no_access') {
|
||||
finalAccess = true;
|
||||
if (import.meta.env.DEV) {
|
||||
logger.success('Acesso concedido por permissão específica do recurso', resourcePermission as any);
|
||||
}
|
||||
} else {
|
||||
// 3. Permissão explícita é 'no_access' — negar acesso
|
||||
finalAccess = false;
|
||||
if (import.meta.env.DEV) {
|
||||
logger.debug('Acesso explicitamente negado pela permissão do recurso');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erro ao verificar permissões de acesso', error);
|
||||
// Por segurança, negar acesso em caso de erro, a menos que seja admin sem negação explícita
|
||||
const resourcePermission = getResourcePermission(resourceKey);
|
||||
finalAccess = isAdmin && resourcePermission !== 'no_access';
|
||||
}
|
||||
|
||||
if (!finalAccess) {
|
||||
logger.debug(`Acesso negado para recurso: ${resourceKey}`);
|
||||
// Verificar se o usuário está ativo no sistema
|
||||
if (profile && profile.status !== 'active') {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-6xl">🔒</div>
|
||||
<h2 className="text-2xl font-semibold text-muted-foreground">
|
||||
Acesso Restrito
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Você não tem permissão para acessar esta funcionalidade. Entre em contato com o administrador do sistema.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Recurso solicitado: <code className="bg-muted px-2 py-1 rounded">{resourceKey}</code>
|
||||
</p>
|
||||
{import.meta.env.DEV && (
|
||||
<div className="text-xs text-muted-foreground mt-2 p-3 bg-muted/50 rounded">
|
||||
<p>Debug info:</p>
|
||||
<p>Admin: {isAdmin ? 'Sim' : 'Não'}</p>
|
||||
<p>Permissão do Recurso: {getResourcePermission(resourceKey)}</p>
|
||||
<p>Permissões Funcionais: {userPermissions ? JSON.stringify(userPermissions) : 'Não carregadas'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
|
||||
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
|
||||
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Migração pro Logto: todos usuários autenticados têm acesso aos recursos.
|
||||
// Sistema de permissões granulares fica pra depois.
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -13,7 +13,6 @@ import { usePecas } from '@/hooks/usePecas';
|
||||
import { useOFs } from '@/hooks/useOFs';
|
||||
import { useComponentesAgrupados } from '@/hooks/useComponentesAgrupados';
|
||||
import { SeletorItensOtimizado } from './SeletorItensOtimizado';
|
||||
import { useApontamentosValidacao } from '@/hooks/useApontamentosValidacao';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ItemDisponivel {
|
||||
@@ -23,9 +22,10 @@ interface ItemDisponivel {
|
||||
tipo: 'peca' | 'componente';
|
||||
quantidade_disponivel: number;
|
||||
processo_atual_permitido: number;
|
||||
nome_processo?: string;
|
||||
}
|
||||
|
||||
// Cache para manter seleções do usuário
|
||||
// Cache local para manter seleções básicas do formulário
|
||||
const formCache = {
|
||||
of_number: '',
|
||||
fase: '',
|
||||
@@ -33,13 +33,6 @@ const formCache = {
|
||||
data_apontamento: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
|
||||
// Cache para itens já processados
|
||||
const itensCache = new Map<string, {
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
timestamp: number;
|
||||
}>();
|
||||
|
||||
export const ApontamentoForm = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
of_number: formCache.of_number || '',
|
||||
@@ -52,55 +45,40 @@ export const ApontamentoForm = () => {
|
||||
});
|
||||
|
||||
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
|
||||
const [itensDisponiveis, setItensDisponiveis] = useState<{
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
}>({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cacheValido, setCacheValido] = useState(false);
|
||||
const [loadingItens, setLoadingItens] = useState(false);
|
||||
const [isProcessingItems, setIsProcessingItems] = useState(false);
|
||||
|
||||
const { criarApontamento, refetch, processos } = useApontamentosProducao();
|
||||
const { pecas } = usePecas();
|
||||
const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos } = useApontamentosProducao();
|
||||
const { pecas, loading: loadingPecas } = usePecas();
|
||||
const { ofs } = useOFs();
|
||||
const { componentesAgrupados } = useComponentesAgrupados(formData.of_number, formData.fase);
|
||||
const {
|
||||
validarSequenciaProcessos,
|
||||
precarregarDados,
|
||||
limparCache: limparCacheValidacao
|
||||
} = useApontamentosValidacao();
|
||||
const { componentesAgrupados, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
|
||||
|
||||
// Buscar fases únicas da OF selecionada
|
||||
const fasesDisponiveis = useMemo(() =>
|
||||
pecas
|
||||
.filter(peca => peca.of_number === formData.of_number)
|
||||
.map(peca => peca.etapa_fase)
|
||||
.filter((fase, index, array) => fase && array.indexOf(fase) === index)
|
||||
.sort(),
|
||||
[pecas, formData.of_number]
|
||||
);
|
||||
const fasesDisponiveis = useMemo(() => {
|
||||
if (!formData.of_number || !pecas.length) return [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
pecas
|
||||
.filter(peca => peca.of_number === formData.of_number)
|
||||
.map(peca => peca.etapa_fase)
|
||||
.filter(Boolean)
|
||||
)
|
||||
).sort();
|
||||
}, [pecas, formData.of_number]);
|
||||
|
||||
// Peças filtradas - memoizado para evite recálculos
|
||||
const filteredPecas = useMemo(() =>
|
||||
pecas.filter(peca =>
|
||||
peca.of_number === formData.of_number &&
|
||||
peca.etapa_fase === formData.fase &&
|
||||
!peca.tem_componentes
|
||||
),
|
||||
[pecas, formData.of_number, formData.fase]
|
||||
);
|
||||
|
||||
// Chave única para cache
|
||||
const cacheKey = useMemo(() =>
|
||||
`${formData.of_number}_${formData.fase}_${formData.processo_id}`,
|
||||
[formData.of_number, formData.fase, formData.processo_id]
|
||||
);
|
||||
// Processo selecionado atualmente
|
||||
const processoSelecionado = useMemo(() => {
|
||||
return processos.find(p => p.id === formData.processo_id) || null;
|
||||
}, [processos, formData.processo_id]);
|
||||
|
||||
// Salvar cache quando seleções básicas mudam
|
||||
const updateCache = useCallback((updates: Partial<typeof formData>) => {
|
||||
Object.assign(formCache, updates);
|
||||
localStorage.setItem('apontamento_cache', JSON.stringify(formCache));
|
||||
try {
|
||||
localStorage.setItem('apontamento_cache', JSON.stringify(formCache));
|
||||
} catch (e) {
|
||||
console.warn('Erro ao salvar cache:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Carregar cache inicial
|
||||
@@ -124,120 +102,129 @@ export const ApontamentoForm = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Função para processar itens com cache
|
||||
const processarItensDisponiveis = useCallback(async () => {
|
||||
// Cálculo reativo de peças e componentes disponíveis para o processo selecionado
|
||||
const itensDisponiveis = useMemo(() => {
|
||||
const { of_number, fase, processo_id } = formData;
|
||||
|
||||
if (!of_number || !fase || !processo_id) {
|
||||
console.log('⚠️ Campos obrigatórios faltando para carregar itens');
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
return;
|
||||
if (!of_number || !fase || !processo_id || !pecas.length) {
|
||||
return { pecasDisponiveis: [], componentesDisponiveis: [] };
|
||||
}
|
||||
|
||||
// Verificar se já temos no cache (válido por 30 segundos)
|
||||
const cached = itensCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
if (cached && (now - cached.timestamp) < 30000) {
|
||||
console.log('📦 Usando itens do cache');
|
||||
setItensDisponiveis(cached);
|
||||
return;
|
||||
}
|
||||
const ordemProcesso = processoSelecionado?.ordem || 1;
|
||||
const nomeProcesso = processoSelecionado?.nome || `${ordemProcesso}`;
|
||||
|
||||
// Aguardar dados das peças e componentes
|
||||
if (filteredPecas.length === 0 && componentesAgrupados.length === 0) {
|
||||
console.log('⏳ Aguardando dados de peças e componentes...');
|
||||
return;
|
||||
}
|
||||
// Reordenar processos para identificar sequencialidade
|
||||
const processosOrdenados = [...processos].sort((a, b) => a.ordem - b.ordem);
|
||||
const indexAtual = processosOrdenados.findIndex(p => p.id === processo_id);
|
||||
const procAtual = processosOrdenados[indexAtual];
|
||||
|
||||
if (isProcessingItems) {
|
||||
console.log('🔄 Já processando itens, aguardando...');
|
||||
return;
|
||||
}
|
||||
// 1. Peças da OF e Fase selecionadas
|
||||
const pecasDaFase = pecas.filter(
|
||||
p => p.of_number === of_number && p.etapa_fase === fase
|
||||
);
|
||||
|
||||
console.log('\n🚀 === PROCESSANDO ITENS COM CACHE ===');
|
||||
console.log(`📋 OF: ${of_number}, Fase: ${fase}, Processo: ${processo_id}`);
|
||||
const pecasDisponiveis: ItemDisponivel[] = [];
|
||||
|
||||
setIsProcessingItems(true);
|
||||
setLoadingItens(true);
|
||||
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);
|
||||
|
||||
try {
|
||||
// Calcular itens disponíveis baseado nos dados existentes
|
||||
console.log('🧮 Calculando itens disponíveis...');
|
||||
const itens = {
|
||||
pecasDisponiveis: filteredPecas.map(peca => ({
|
||||
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;
|
||||
}
|
||||
|
||||
if (processoAnteriorValido) {
|
||||
qtdDisponivelParaEntrar = apontamentos
|
||||
.filter(a => a.tipo_apontamento === 'peca' && a.peca_id === peca.id && a.processo_id === processoAnteriorValido.id)
|
||||
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
const saldoDisponivel = Math.max(0, qtdDisponivelParaEntrar - totalApontadoAtual);
|
||||
|
||||
if (saldoDisponivel > 0) {
|
||||
pecasDisponiveis.push({
|
||||
id: peca.id,
|
||||
marca: peca.marca,
|
||||
descricao: peca.descricao,
|
||||
tipo: 'peca' as const,
|
||||
quantidade_disponivel: peca.quantidade,
|
||||
processo_atual_permitido: 1
|
||||
})),
|
||||
componentesDisponiveis: componentesAgrupados.map(comp => ({
|
||||
id: comp.componente_ids[0] || '', // Use primeiro ID do array
|
||||
marca: comp.marca_componente,
|
||||
descricao: comp.descricao || '',
|
||||
tipo: 'componente' as const,
|
||||
quantidade_disponivel: comp.quantidade_total,
|
||||
processo_atual_permitido: 1
|
||||
}))
|
||||
};
|
||||
descricao: peca.descricao || '',
|
||||
tipo: 'peca',
|
||||
quantidade_disponivel: saldoDisponivel,
|
||||
processo_atual_permitido: ordemProcesso,
|
||||
nome_processo: nomeProcesso
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ Itens calculados:', {
|
||||
pecas: itens.pecasDisponiveis.length,
|
||||
componentes: itens.componentesDisponiveis.length
|
||||
// 2. Componentes da OF e Fase selecionadas
|
||||
const componentesDisponiveis: ItemDisponivel[] = [];
|
||||
|
||||
if (componentesAgrupados && componentesAgrupados.length > 0) {
|
||||
componentesAgrupados.forEach(comp => {
|
||||
let qtdDisponivelParaEntrarComp = Number(comp.quantidade_total) || 0;
|
||||
|
||||
if (indexAtual > 0) {
|
||||
const processoAnteriorValido = processosOrdenados[indexAtual - 1];
|
||||
if (processoAnteriorValido) {
|
||||
qtdDisponivelParaEntrarComp = apontamentos
|
||||
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processoAnteriorValido.id)
|
||||
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
const totalApontadoComp = apontamentos
|
||||
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processo_id)
|
||||
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||
|
||||
const saldoComp = Math.max(0, qtdDisponivelParaEntrarComp - totalApontadoComp);
|
||||
|
||||
if (saldoComp > 0) {
|
||||
componentesDisponiveis.push({
|
||||
id: comp.componente_ids[0] || '',
|
||||
marca: comp.marca_componente,
|
||||
descricao: comp.descricao || comp.perfil || '',
|
||||
tipo: 'componente',
|
||||
quantidade_disponivel: saldoComp,
|
||||
processo_atual_permitido: ordemProcesso,
|
||||
nome_processo: nomeProcesso
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Salvar no cache
|
||||
itensCache.set(cacheKey, {
|
||||
...itens,
|
||||
timestamp: now
|
||||
});
|
||||
|
||||
// 5. Atualizar estado
|
||||
setItensDisponiveis(itens);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao processar itens:', error);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
} finally {
|
||||
setLoadingItens(false);
|
||||
setIsProcessingItems(false);
|
||||
}
|
||||
}, [
|
||||
formData.of_number,
|
||||
formData.fase,
|
||||
formData.processo_id
|
||||
]);
|
||||
|
||||
// Callback para atualizar dados
|
||||
const updateData = useCallback(() => {
|
||||
// 4. Atualizar dados para nova seleção
|
||||
console.log('✅ Dados atualizados para nova seleção de OF/processo');
|
||||
}, []);
|
||||
return { pecasDisponiveis, componentesDisponiveis };
|
||||
}, [formData, pecas, apontamentos, processoSelecionado, componentesAgrupados, processos]);
|
||||
|
||||
// Efeito controlado para carregar itens
|
||||
// Sincronizar item selecionado caso não exista mais na lista disponível
|
||||
useEffect(() => {
|
||||
if (formData.of_number && formData.fase && formData.processo_id) {
|
||||
// Usar timeout para evitar chamadas excessivas
|
||||
const timeoutId = setTimeout(() => {
|
||||
processarItensDisponiveis();
|
||||
}, 300);
|
||||
if (itemSelecionado) {
|
||||
const listaAtual = itemSelecionado.tipo === 'peca'
|
||||
? itensDisponiveis.pecasDisponiveis
|
||||
: itensDisponiveis.componentesDisponiveis;
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else {
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
const itemAindaExiste = listaAtual.find(i => i.id === itemSelecionado.id);
|
||||
if (!itemAindaExiste) {
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({ ...prev, quantidade_produzida: '', todas_disponiveis: false }));
|
||||
} else if (itemAindaExiste.quantidade_disponivel !== itemSelecionado.quantidade_disponivel) {
|
||||
setItemSelecionado(itemAindaExiste);
|
||||
}
|
||||
}
|
||||
}, [formData.of_number, formData.fase, formData.processo_id]);
|
||||
|
||||
// Reset do item selecionado quando dados mudam
|
||||
useEffect(() => {
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
}, [formData.of_number, formData.fase, formData.processo_id]);
|
||||
}, [itensDisponiveis, itemSelecionado]);
|
||||
|
||||
// Auto-preenchimento da quantidade quando "todas disponíveis" é marcado
|
||||
useEffect(() => {
|
||||
@@ -259,9 +246,9 @@ export const ApontamentoForm = () => {
|
||||
const tipoTexto = tipo === 'peca' ? 'peças' : 'componentes';
|
||||
|
||||
const confirmacao = window.confirm(
|
||||
`Deseja registrar ${totalItens} ${tipoTexto} com suas respectivas quantidades totais?\n\n` +
|
||||
`Deseja registrar ${totalItens} ${tipoTexto} com suas respectivas quantidades totais disponíveis?\n\n` +
|
||||
`Total de itens: ${totalItens}\n` +
|
||||
`Processo: ${formData.processo_id || 'N/A'}`
|
||||
`Processo: ${processoSelecionado?.nome || 'N/A'}`
|
||||
);
|
||||
|
||||
if (!confirmacao) return;
|
||||
@@ -273,7 +260,7 @@ export const ApontamentoForm = () => {
|
||||
try {
|
||||
for (const item of items) {
|
||||
try {
|
||||
const apontamentoData: any = {
|
||||
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: item.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
@@ -303,11 +290,8 @@ export const ApontamentoForm = () => {
|
||||
|
||||
if (sucessos > 0) {
|
||||
toast.success(`${sucessos} ${tipoTexto} registradas com sucesso!${erros > 0 ? ` (${erros} com erro)` : ''}`);
|
||||
|
||||
await Promise.all([
|
||||
refetch(),
|
||||
resetFormForNewEntry()
|
||||
]);
|
||||
await refetch();
|
||||
resetFormForNewEntry();
|
||||
} else {
|
||||
toast.error(`Erro ao registrar ${tipoTexto} em lote`);
|
||||
}
|
||||
@@ -331,9 +315,6 @@ export const ApontamentoForm = () => {
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
// Limpar cache relacionado
|
||||
itensCache.clear();
|
||||
};
|
||||
|
||||
const handleFaseChange = (fase: string) => {
|
||||
@@ -346,9 +327,6 @@ export const ApontamentoForm = () => {
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ fase: fase, processo_id: '' });
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
// Limpar cache relacionado
|
||||
itensCache.clear();
|
||||
};
|
||||
|
||||
const handleProcessoChange = (processoId: string) => {
|
||||
@@ -360,7 +338,6 @@ export const ApontamentoForm = () => {
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ processo_id: processoId });
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
};
|
||||
|
||||
const handleItemSelect = (item: ItemDisponivel) => {
|
||||
@@ -387,10 +364,7 @@ export const ApontamentoForm = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
// Função para resetar form e atualizar dados
|
||||
const resetFormForNewEntry = async () => {
|
||||
console.log('🔄 Resetando formulário e limpando cache...');
|
||||
|
||||
const resetFormForNewEntry = () => {
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -398,9 +372,6 @@ export const ApontamentoForm = () => {
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
|
||||
// Limpar cache e forçar recarregamento
|
||||
itensCache.clear();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -413,7 +384,7 @@ export const ApontamentoForm = () => {
|
||||
|
||||
const quantidade = parseInt(formData.quantidade_produzida);
|
||||
|
||||
if (quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
|
||||
if (isNaN(quantidade) || quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
|
||||
toast.error('Quantidade inválida');
|
||||
return;
|
||||
}
|
||||
@@ -421,16 +392,13 @@ export const ApontamentoForm = () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Validação básica - pode ser expandida depois
|
||||
console.log('✅ Validação de sequência aprovada');
|
||||
|
||||
const apontamentoData: any = {
|
||||
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: itemSelecionado.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: quantidade,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: formData.observacoes || null
|
||||
observacoes: formData.observacoes || undefined
|
||||
};
|
||||
|
||||
if (itemSelecionado.tipo === 'componente') {
|
||||
@@ -443,11 +411,8 @@ export const ApontamentoForm = () => {
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Apontamento registrado com sucesso!');
|
||||
|
||||
await Promise.all([
|
||||
refetch(),
|
||||
resetFormForNewEntry()
|
||||
]);
|
||||
await refetch();
|
||||
resetFormForNewEntry();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro no submit:', error);
|
||||
@@ -475,13 +440,11 @@ export const ApontamentoForm = () => {
|
||||
todas_disponiveis: false
|
||||
});
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
setCacheValido(false);
|
||||
itensCache.clear();
|
||||
toast.success('Cache limpo com sucesso!');
|
||||
};
|
||||
|
||||
const processoSelecionado = null;
|
||||
const isLoadingItens = loadingPecas || loadingApontamentos || loadingComponentes;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
@@ -583,15 +546,12 @@ export const ApontamentoForm = () => {
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{processoSelecionado.ordem === 1
|
||||
? `Processo inicial: ${processoSelecionado.nome}. Todos os itens estão disponíveis.`
|
||||
: `Processo ${processoSelecionado.ordem}: ${processoSelecionado.nome}. Apenas itens que passaram pelos processos anteriores estão disponíveis.`
|
||||
}
|
||||
{`Processo: ${processoSelecionado.ordem}. ${processoSelecionado.nome}. Selecione as peças ou componentes com saldo pendente para apontar.`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Seletor de itens otimizado com funcionalidade de lote - agora com scroll */}
|
||||
{/* Seletor de itens otimizado com funcionalidade de lote */}
|
||||
{formData.processo_id && (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<SeletorItensOtimizado
|
||||
@@ -600,7 +560,7 @@ export const ApontamentoForm = () => {
|
||||
itemSelecionado={itemSelecionado}
|
||||
onItemSelect={handleItemSelect}
|
||||
onBatchSelect={handleBatchSelect}
|
||||
loading={loadingItens || isProcessingItems}
|
||||
loading={isLoadingItens}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -660,7 +620,7 @@ export const ApontamentoForm = () => {
|
||||
</h4>
|
||||
{!itemSelecionado ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Selecione um item para ver as informações ou use os checkboxes para registro em lote
|
||||
Selecione um item para ver as informações ou use os botões para registro em lote
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
@@ -668,7 +628,7 @@ export const ApontamentoForm = () => {
|
||||
<div><strong>Marca:</strong> {itemSelecionado.marca}</div>
|
||||
<div><strong>OF:</strong> {formData.of_number}</div>
|
||||
<div><strong>Fase:</strong> {formData.fase}</div>
|
||||
<div><strong>Processo:</strong> {processoSelecionado?.nome || 'N/A'}</div>
|
||||
<div><strong>Processo:</strong> {processoSelecionado ? `${processoSelecionado.ordem}. ${processoSelecionado.nome}` : 'N/A'}</div>
|
||||
<div><strong>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
|
||||
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
|
||||
</div>
|
||||
@@ -676,7 +636,7 @@ export const ApontamentoForm = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Botões movidos para baixo do card de informações */}
|
||||
{/* Botões de ação */}
|
||||
<div className="flex justify-end space-x-2">
|
||||
{formData.of_number && formData.fase && formData.processo_id && (
|
||||
<Button
|
||||
@@ -690,7 +650,7 @@ export const ApontamentoForm = () => {
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || !itemSelecionado || !formData.processo_id || loadingItens || isProcessingItems}
|
||||
disabled={saving || !itemSelecionado || !formData.processo_id || isLoadingItens}
|
||||
className="min-w-32"
|
||||
>
|
||||
{saving ? 'Salvando...' : 'Registrar Apontamento'}
|
||||
|
||||
@@ -42,9 +42,9 @@ export const ApontamentosListOtimizado: React.FC = () => {
|
||||
.select(`
|
||||
of_number,
|
||||
quantidade_produzida,
|
||||
peca:pecas(marca),
|
||||
componente:componentes_peca(marca_componente),
|
||||
processo:processos_fabricacao(nome)
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(marca),
|
||||
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome)
|
||||
`)
|
||||
.eq('id', apontamentoId)
|
||||
.single();
|
||||
@@ -72,9 +72,10 @@ export const ApontamentosListOtimizado: React.FC = () => {
|
||||
await refetch();
|
||||
console.log('Lista de apontamentos atualizada');
|
||||
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro completo ao reverter apontamento:', error);
|
||||
toast.error(`Erro ao reverter apontamento: ${error.message || 'Erro desconhecido'}`);
|
||||
const msg = error instanceof Error ? error.message : 'Erro desconhecido';
|
||||
toast.error(`Erro ao reverter apontamento: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,12 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AlertTriangle, Calendar, Hash, TrendingUp, ExternalLink } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { InconsistenciaItem } from '@/hooks/useAuditoriaInconsistencias';
|
||||
|
||||
interface InconsistenciaDetalhesModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
inconsistencia: any;
|
||||
inconsistencia: InconsistenciaItem | null;
|
||||
}
|
||||
|
||||
export const InconsistenciaDetalhesModal: React.FC<InconsistenciaDetalhesModalProps> = ({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ interface CronogramaFormProps {
|
||||
cronograma?: CronogramaOf | null;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
onSaveSuccess?: () => void;
|
||||
}
|
||||
|
||||
const processosDefault = [
|
||||
@@ -30,7 +31,19 @@ const processosDefault = [
|
||||
'Aceite/DB'
|
||||
];
|
||||
|
||||
export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onClose, isOpen }) => {
|
||||
interface OFOption {
|
||||
id: string;
|
||||
num_of: string;
|
||||
descritivo: string;
|
||||
}
|
||||
|
||||
interface GestorOption {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onClose, isOpen, onSaveSuccess }) => {
|
||||
const { saveCronograma, getCronogramaPorOf } = useCronogramaOperations();
|
||||
const [formData, setFormData] = useState({
|
||||
of_id: '',
|
||||
@@ -44,8 +57,8 @@ export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onCl
|
||||
})) as ProcessoCronograma[]
|
||||
});
|
||||
|
||||
const [ofs, setOfs] = useState<any[]>([]);
|
||||
const [gestores, setGestores] = useState<any[]>([]);
|
||||
const [ofs, setOfs] = useState<OFOption[]>([]);
|
||||
const [gestores, setGestores] = useState<GestorOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -228,6 +241,9 @@ export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onCl
|
||||
setLoading(false);
|
||||
|
||||
if (success) {
|
||||
if (onSaveSuccess) {
|
||||
onSaveSuccess();
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { CronogramaOf } from '@/hooks/useCronogramas';
|
||||
import { useBrandSettings } from '@/hooks/useBrandSettings';
|
||||
import jsPDF from 'jspdf';
|
||||
@@ -15,30 +14,49 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
|
||||
const { brandSettings } = useBrandSettings();
|
||||
|
||||
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => {
|
||||
return differenceInDays(parseISO(dataFim), parseISO(dataInicio)) + 1;
|
||||
try {
|
||||
const inc = parseISO(dataInicio);
|
||||
const fim = parseISO(dataFim);
|
||||
const diff = differenceInDays(fim, inc);
|
||||
return diff >= 0 ? diff + 1 : 1;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
const gerarPDF = async () => {
|
||||
const doc = new jsPDF('portrait', 'mm', 'a4');
|
||||
const gerarPDF = useCallback(async () => {
|
||||
// Formato Paisagem A4 (297mm x 210mm) - Amplo espaço para gráfico Gantt e tabela
|
||||
const doc = new jsPDF('landscape', 'mm', 'a4');
|
||||
|
||||
// Configurações de cores (tons profissionais em cinza)
|
||||
const corCinzaClaro = [200, 200, 200]; // Cinza claro para cabeçalho
|
||||
const corCinzaMedio = [150, 150, 150]; // Cinza médio
|
||||
const corCinzaEscuro = [80, 80, 80]; // Cinza escuro para texto
|
||||
const corBranco = [255, 255, 255]; // Branco
|
||||
const pageWidth = doc.internal.pageSize.width; // 297mm
|
||||
const pageHeight = doc.internal.pageSize.height; // 210mm
|
||||
const margin = 12;
|
||||
const usableWidth = pageWidth - (margin * 2); // 273mm
|
||||
|
||||
const pageWidth = doc.internal.pageSize.width;
|
||||
const pageHeight = doc.internal.pageSize.height;
|
||||
const margin = 15;
|
||||
const usableWidth = pageWidth - (margin * 2);
|
||||
let yPosition = margin;
|
||||
// Palette de Cores Executiva Modern (Otimizada para Impressora Monocromática)
|
||||
const cPrimary = [241, 245, 249]; // Slate 100 (Fundo do cabeçalho)
|
||||
const cSecondary = [226, 232, 240]; // Slate 200
|
||||
const cAccent = [71, 85, 105]; // Slate 600 (Highlight)
|
||||
const cTextDark = [15, 23, 42]; // Slate 900 (Texto principal)
|
||||
const cTextMuted = [71, 85, 105]; // Slate 600 (Subtítulos)
|
||||
const cBorder = [203, 213, 225]; // Slate 300 (Bordas)
|
||||
const cBgCard = [255, 255, 255]; // Branco (Fundo de cards, economiza tinta)
|
||||
const cWhite = [255, 255, 255];
|
||||
|
||||
// CABEÇALHO COMPACTO
|
||||
// Fundo do cabeçalho - altura reduzida para 25px
|
||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
||||
doc.rect(0, 0, pageWidth, 25, 'F');
|
||||
// ----------------------------------------------------
|
||||
// 1. CABEÇALHO EXECUTIVO (HEADER SLATE DARK BANNER)
|
||||
// ----------------------------------------------------
|
||||
const headerHeight = 28;
|
||||
doc.setFillColor(cPrimary[0], cPrimary[1], cPrimary[2]);
|
||||
doc.rect(0, 0, pageWidth, headerHeight, 'F');
|
||||
|
||||
// Logo da empresa (tamanho reduzido)
|
||||
// Accent line abaixo do cabeçalho
|
||||
doc.setFillColor(cAccent[0], cAccent[1], cAccent[2]);
|
||||
doc.rect(0, headerHeight, pageWidth, 1.5, 'F');
|
||||
|
||||
let headerTextX = margin;
|
||||
|
||||
// Logo da Empresa com proporção preservada (Sem distorção)
|
||||
if (brandSettings.logo_url) {
|
||||
try {
|
||||
const img = new Image();
|
||||
@@ -49,237 +67,324 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
|
||||
img.src = brandSettings.logo_url!;
|
||||
});
|
||||
|
||||
// Logo menor - 20x15
|
||||
const logoWidth = 20;
|
||||
const logoHeight = 15;
|
||||
doc.addImage(img, 'PNG', margin, 5, logoWidth, logoHeight);
|
||||
// Calcular aspect ratio para não espremer a imagem
|
||||
const maxH = 16;
|
||||
const maxW = 45;
|
||||
let imgW = maxH * (img.naturalWidth / img.naturalHeight);
|
||||
let imgH = maxH;
|
||||
if (imgW > maxW) {
|
||||
imgW = maxW;
|
||||
imgH = maxW * (img.naturalHeight / img.naturalWidth);
|
||||
}
|
||||
|
||||
const logoY = (headerHeight - imgH) / 2;
|
||||
doc.addImage(img, 'PNG', margin, logoY, imgW, imgH);
|
||||
headerTextX = margin + imgW + 8;
|
||||
} catch (error) {
|
||||
console.log('Erro ao carregar logo:', error);
|
||||
console.log('Erro ao carregar logo no PDF:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Nome da empresa e título
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(14);
|
||||
// Título da Empresa & Documento
|
||||
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
|
||||
doc.setFontSize(15);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(brandSettings.company_name, brandSettings.logo_url ? margin + 25 : margin, 12);
|
||||
doc.text(brandSettings.company_name || 'TrackSteel', headerTextX, 12);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('CRONOGRAMA DE PRODUÇÃO', brandSettings.logo_url ? margin + 25 : margin, 18);
|
||||
|
||||
yPosition = 35;
|
||||
|
||||
// TÍTULO DO CRONOGRAMA
|
||||
doc.setFillColor(240, 240, 240);
|
||||
doc.rect(margin, yPosition - 3, usableWidth, 12, 'F');
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
const titulo = `OF: ${cronograma.ordem_fabricacao?.num_of} - Revisão: ${cronograma.revisao}`;
|
||||
doc.text(titulo, margin + 3, yPosition + 3);
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Gestor: ${cronograma.gestor_profile?.full_name || 'N/A'}`, margin + 3, yPosition + 8);
|
||||
|
||||
yPosition += 18;
|
||||
|
||||
// RESUMO EXECUTIVO COMPACTO
|
||||
const todasAsDatas = cronograma.processos.flatMap(p => [p.data_inicio, p.data_fim]);
|
||||
const dataInicioTotal = new Date(Math.min(...todasAsDatas.map(d => new Date(d).getTime())));
|
||||
const dataFimTotal = new Date(Math.max(...todasAsDatas.map(d => new Date(d).getTime())));
|
||||
const duracaoTotal = differenceInDays(dataFimTotal, dataInicioTotal) + 1;
|
||||
|
||||
// Boxes do resumo em linha
|
||||
const boxWidth = (usableWidth) / 3;
|
||||
|
||||
doc.setFillColor(corBranco[0], corBranco[1], corBranco[2]);
|
||||
doc.setDrawColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
|
||||
|
||||
// Box 1
|
||||
doc.rect(margin, yPosition, boxWidth - 2, 15, 'FD');
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${cronograma.processos.length} Processos`, margin + 3, yPosition + 6);
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Total', margin + 3, yPosition + 11);
|
||||
|
||||
// Box 2
|
||||
doc.rect(margin + boxWidth, yPosition, boxWidth - 2, 15, 'FD');
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${duracaoTotal} dias`, margin + boxWidth + 3, yPosition + 6);
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Duração', margin + boxWidth + 3, yPosition + 11);
|
||||
|
||||
// Box 3
|
||||
doc.rect(margin + boxWidth * 2, yPosition, boxWidth - 2, 15, 'FD');
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${format(dataInicioTotal, 'dd/MM', { locale: ptBR })} - ${format(dataFimTotal, 'dd/MM', { locale: ptBR })}`, margin + boxWidth * 2 + 3, yPosition + 6);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Período', margin + boxWidth * 2 + 3, yPosition + 11);
|
||||
|
||||
yPosition += 22;
|
||||
|
||||
// TABELA COMPACTA
|
||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
||||
doc.rect(margin, yPosition, usableWidth, 8, 'F');
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('CRONOGRAMA DETALHADO', margin + 3, yPosition + 5);
|
||||
|
||||
yPosition += 10;
|
||||
|
||||
// Cabeçalho da tabela - altura reduzida
|
||||
const colWidths = [usableWidth * 0.4, usableWidth * 0.2, usableWidth * 0.2, usableWidth * 0.2];
|
||||
const headers = ['Processo', 'Início', 'Fim', 'Duração'];
|
||||
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(margin, yPosition, usableWidth, 7, 'F');
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('RELATÓRIO EXECUTIVO DE CRONOGRAMA DE PRODUÇÃO', headerTextX, 19);
|
||||
|
||||
let xPosition = margin;
|
||||
headers.forEach((header, index) => {
|
||||
doc.text(header, xPosition + 2, yPosition + 5);
|
||||
xPosition += colWidths[index];
|
||||
});
|
||||
// Badges no canto direito do cabeçalho
|
||||
const badgeY = 8;
|
||||
const badgeRight = pageWidth - margin;
|
||||
|
||||
yPosition += 7;
|
||||
|
||||
// Linhas da tabela - altura reduzida em 40% (de 12 para 7.2)
|
||||
doc.setFont('helvetica', 'normal');
|
||||
// Pill de OF
|
||||
const ofNum = cronograma.ordem_fabricacao?.num_of || 'N/A';
|
||||
doc.setFillColor(cSecondary[0], cSecondary[1], cSecondary[2]);
|
||||
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||
doc.roundedRect(badgeRight - 65, badgeY, 35, 12, 2, 2, 'FD');
|
||||
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
|
||||
doc.setFontSize(8);
|
||||
|
||||
cronograma.processos
|
||||
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
|
||||
.forEach((processo, index) => {
|
||||
// Alternância de cores
|
||||
if (index % 2 === 0) {
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.rect(margin, yPosition, usableWidth, 7, 'F');
|
||||
}
|
||||
|
||||
xPosition = margin;
|
||||
const rowData = [
|
||||
processo.nome_processo.length > 25 ? processo.nome_processo.substring(0, 25) + '...' : processo.nome_processo,
|
||||
format(parseISO(processo.data_inicio), 'dd/MM', { locale: ptBR }),
|
||||
format(parseISO(processo.data_fim), 'dd/MM', { locale: ptBR }),
|
||||
`${calcularDiasCorridos(processo.data_inicio, processo.data_fim)}d`
|
||||
];
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
rowData.forEach((data, colIndex) => {
|
||||
doc.text(data, xPosition + 2, yPosition + 5);
|
||||
xPosition += colWidths[colIndex];
|
||||
});
|
||||
|
||||
yPosition += 7;
|
||||
});
|
||||
|
||||
yPosition += 10;
|
||||
|
||||
// GRÁFICO DE GANTT VISUAL MELHORADO
|
||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
||||
doc.rect(margin, yPosition, usableWidth, 8, 'F');
|
||||
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('LINHA DO TEMPO VISUAL', margin + 3, yPosition + 5);
|
||||
doc.text(`OF: ${ofNum}`, badgeRight - 47.5, badgeY + 7.5, { align: 'center' });
|
||||
|
||||
yPosition += 12;
|
||||
// Pill de Revisão
|
||||
doc.setFillColor(cBgCard[0], cBgCard[1], cBgCard[2]);
|
||||
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||
doc.roundedRect(badgeRight - 27, badgeY, 27, 12, 2, 2, 'FD');
|
||||
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
|
||||
doc.text(`REV: ${cronograma.revisao || 1}`, badgeRight - 13.5, badgeY + 7.5, { align: 'center' });
|
||||
|
||||
// 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);
|
||||
let yPos = headerHeight + 8;
|
||||
|
||||
// Linha de base
|
||||
doc.setDrawColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
|
||||
doc.line(margin, yPosition, margin + usableWidth, yPosition);
|
||||
// ----------------------------------------------------
|
||||
// 2. CARDS DE RESUMO / KPIS
|
||||
// ----------------------------------------------------
|
||||
const processos = (cronograma.processos || []).sort((a, b) => (a.ordem || 0) - (b.ordem || 0));
|
||||
|
||||
yPosition += 3;
|
||||
// Cálculo das datas gerais
|
||||
let dataInicioTotal = new Date();
|
||||
let dataFimTotal = new Date();
|
||||
let duracaoTotal = 0;
|
||||
|
||||
// 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
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const cardGap = 5;
|
||||
const cardWidth = (usableWidth - (cardGap * 3)) / 4;
|
||||
const cardHeight = 18;
|
||||
|
||||
const cardsData = [
|
||||
{
|
||||
title: 'ESTRUTURA / OF',
|
||||
value: `${ofNum} - ${cronograma.ordem_fabricacao?.descritivo || 'Sem descrição'}`
|
||||
},
|
||||
{
|
||||
title: 'GESTOR / RESPONSÁVEL',
|
||||
value: cronograma.gestor_profile?.full_name || 'Não atribuído'
|
||||
},
|
||||
{
|
||||
title: 'DURAÇÃO & PROCESSO',
|
||||
value: `${duracaoTotal} Dias Corridos (${processos.length} etapas)`
|
||||
},
|
||||
{
|
||||
title: 'PERÍODO PREVISTO',
|
||||
value: `${format(dataInicioTotal, 'dd/MM/yyyy')} a ${format(dataFimTotal, 'dd/MM/yyyy')}`
|
||||
}
|
||||
];
|
||||
|
||||
const nomeAreaWidth = 60; // Área reservada para nomes dos processos
|
||||
const graficoWidth = usableWidth - nomeAreaWidth - 5;
|
||||
cardsData.forEach((card, idx) => {
|
||||
const cardX = margin + idx * (cardWidth + cardGap);
|
||||
|
||||
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);
|
||||
// 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');
|
||||
|
||||
const barraInicio = margin + nomeAreaWidth + (diasDoInicio / duracaoTotal) * graficoWidth;
|
||||
const barraLargura = Math.max(2, (duracaoProcesso / duracaoTotal) * graficoWidth);
|
||||
// Top Accent Line
|
||||
doc.setFillColor(cAccent[0], cAccent[1], cAccent[2]);
|
||||
doc.rect(cardX, yPos, cardWidth, 1, 'F');
|
||||
|
||||
// Nome do processo na área reservada
|
||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
const nomeProcesso = processo.nome_processo.length > 20 ?
|
||||
processo.nome_processo.substring(0, 20) + '...' :
|
||||
processo.nome_processo;
|
||||
doc.text(nomeProcesso, margin, yPosition + 2);
|
||||
// Card Title
|
||||
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
|
||||
doc.setFontSize(7);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(card.title, cardX + 3, yPos + 5.5);
|
||||
|
||||
// Barra colorida
|
||||
const cor = cores[index % cores.length];
|
||||
doc.setFillColor(cor[0], cor[1], cor[2]);
|
||||
doc.rect(barraInicio, yPosition - 1, barraLargura, 6, 'F');
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Duração na barra (se houver espaço)
|
||||
if (barraLargura > 8) {
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(6);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${duracaoProcesso}d`, barraInicio + barraLargura/2 - 2, yPosition + 2);
|
||||
}
|
||||
yPos += cardHeight + 8;
|
||||
|
||||
yPosition += 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;
|
||||
|
||||
// RODAPÉ COMPACTO
|
||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
||||
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F');
|
||||
// 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.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
||||
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', '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);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
|
||||
// Download do PDF
|
||||
const nomeArquivo = `cronograma_${cronograma.ordem_fabricacao?.num_of}_rev${cronograma.revisao}_${format(new Date(), 'ddMMyyyy')}.pdf`;
|
||||
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);
|
||||
}
|
||||
|
||||
tableY += rowHeight;
|
||||
});
|
||||
|
||||
// Borda ao redor das caixas
|
||||
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||
doc.rect(margin, yPos + 6, tableWidth, (processos.length + 1) * rowHeight, 'S');
|
||||
doc.rect(ganttX, yPos + 6, ganttWidth, (processos.length + 1) * rowHeight, 'S');
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 4. RODAPÉ EXECUTIVO (FOOTER)
|
||||
// ----------------------------------------------------
|
||||
const footerY = pageHeight - 10;
|
||||
|
||||
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||
doc.line(margin, footerY - 3, pageWidth - margin, footerY - 3);
|
||||
|
||||
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
doc.text(
|
||||
`${brandSettings.company_name || 'TrackSteel'} — Sistema Integrado de Gestão Estrutural`,
|
||||
margin,
|
||||
footerY + 1
|
||||
);
|
||||
|
||||
const nowFormatted = format(new Date(), 'dd/MM/yyyy HH:mm', { locale: ptBR });
|
||||
doc.text(
|
||||
`Documento Gerado em ${nowFormatted} | Página 1 de 1`,
|
||||
pageWidth - margin,
|
||||
footerY + 1,
|
||||
{ align: 'right' }
|
||||
);
|
||||
|
||||
// Download automático do PDF
|
||||
const safeOF = (cronograma.ordem_fabricacao?.num_of || 'OF').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const nomeArquivo = `cronograma_${safeOF}_rev${cronograma.revisao || 1}_${format(new Date(), 'ddMMyyyy')}.pdf`;
|
||||
doc.save(nomeArquivo);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
}, [cronograma, onComplete, brandSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -287,7 +392,7 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [cronograma, onComplete, brandSettings]);
|
||||
}, [gerarPDF]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { format, parseISO, eachDayOfInterval } from 'date-fns';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip
|
||||
} from 'recharts';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Scale, Percent, FilterX } from 'lucide-react';
|
||||
|
||||
interface GraficoMestreProps {
|
||||
processos: DashboardProcesso[];
|
||||
@@ -11,126 +20,324 @@ interface GraficoMestreProps {
|
||||
processoSelecionado?: string | null;
|
||||
}
|
||||
|
||||
// Ordem sequencial padrão de produção para ordenação linear
|
||||
const ORDEM_PROCESSOS_LINEAR = [
|
||||
'detalhamento',
|
||||
'corte',
|
||||
'solda',
|
||||
'pintura/galv',
|
||||
'pintura',
|
||||
'galvanizacao',
|
||||
'expedicao',
|
||||
'expedição',
|
||||
'montagem',
|
||||
'aceite/db',
|
||||
'aceite',
|
||||
'concluido',
|
||||
'concluído'
|
||||
];
|
||||
|
||||
// Paleta de cores vibrantes e distintas para cada processo
|
||||
const CORES_PROCESSOS: Record<string, string> = {
|
||||
'detalhamento': '#a855f7', // Roxo / Purple
|
||||
'corte': '#3b82f6', // Azul Royal
|
||||
'solda': '#f97316', // Laranja Vibrante
|
||||
'pintura/galv': '#ec4899', // Rosa / Magenta
|
||||
'pintura': '#ec4899', // Rosa
|
||||
'galvanizacao': '#ec4899', // Rosa
|
||||
'expedicao': '#06b6d4', // Ciano
|
||||
'expedição': '#06b6d4', // Ciano
|
||||
'montagem': '#14b8a6', // Verde Água / Teal
|
||||
'aceite/db': '#6366f1', // Índigo
|
||||
'aceite': '#6366f1', // Índigo
|
||||
'concluido': '#10b981', // Verde Esmeralda
|
||||
'concluído': '#10b981' // Verde Esmeralda
|
||||
};
|
||||
|
||||
// Paleta de fallback se houver processo fora do padrão
|
||||
const CORES_FALLBACK = [
|
||||
'#a855f7', '#3b82f6', '#f97316', '#ec4899',
|
||||
'#06b6d4', '#14b8a6', '#6366f1', '#10b981', '#eab308'
|
||||
];
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
dataKey: string;
|
||||
}>;
|
||||
label?: string;
|
||||
modoExibicao: 'kg' | 'percent';
|
||||
selectedProcesso: string | null;
|
||||
}
|
||||
|
||||
// Componente Tooltip declarado fora da renderização principal para evitar recriação no render
|
||||
const CustomTooltip: React.FC<CustomTooltipProps> = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
modoExibicao,
|
||||
selectedProcesso
|
||||
}) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-popover/95 backdrop-blur-md border border-border shadow-2xl rounded-xl p-3 text-popover-foreground min-w-[200px] z-50">
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2 mb-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Data</span>
|
||||
<span className="text-xs font-bold bg-primary/10 text-primary px-2 py-0.5 rounded-md">{label}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry, index) => {
|
||||
const isHighlight = !selectedProcesso || selectedProcesso === entry.name;
|
||||
return (
|
||||
<div
|
||||
key={`item-${index}`}
|
||||
className={`flex items-center justify-between text-xs transition-opacity ${
|
||||
isHighlight ? 'opacity-100 font-medium' : 'opacity-40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="truncate max-w-[130px]">{entry.name}</span>
|
||||
</div>
|
||||
<span className="font-mono font-semibold ml-2">
|
||||
{modoExibicao === 'kg'
|
||||
? `${entry.value.toLocaleString('pt-BR')} kg`
|
||||
: `${entry.value.toFixed(1)}%`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GraficoMestre: React.FC<GraficoMestreProps> = ({
|
||||
processos,
|
||||
onProcessoClick,
|
||||
processoSelecionado
|
||||
processoSelecionado: processoSelecionadoProp
|
||||
}) => {
|
||||
// Processar dados dos gráficos individuais para criar um gráfico sobreposto
|
||||
const dadosGraficoSobreposto = useMemo(() => {
|
||||
if (!processos || processos.length === 0) return [];
|
||||
const [modoExibicao, setModoExibicao] = useState<'kg' | 'percent'>('kg');
|
||||
const [selectedProcessoInternal, setSelectedProcessoInternal] = useState<string | null>(null);
|
||||
|
||||
const selectedProcesso = processoSelecionadoProp !== undefined ? processoSelecionadoProp : selectedProcessoInternal;
|
||||
|
||||
const handleSelectProcesso = (nome: string) => {
|
||||
const nextSelected = selectedProcesso === nome ? null : nome;
|
||||
setSelectedProcessoInternal(nextSelected);
|
||||
if (onProcessoClick) {
|
||||
onProcessoClick(nextSelected || '');
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Ordenar processos na sequência linear real da produção
|
||||
const processosOrdenados = useMemo(() => {
|
||||
if (!processos) return [];
|
||||
|
||||
return [...processos].sort((a, b) => {
|
||||
const nomeA = a.nome.toLowerCase().trim();
|
||||
const nomeB = b.nome.toLowerCase().trim();
|
||||
|
||||
const indexA = ORDEM_PROCESSOS_LINEAR.findIndex(p => nomeA.includes(p));
|
||||
const indexB = ORDEM_PROCESSOS_LINEAR.findIndex(p => nomeB.includes(p));
|
||||
|
||||
const posA = indexA !== -1 ? indexA : 99;
|
||||
const posB = indexB !== -1 ? indexB : 99;
|
||||
|
||||
return posA - posB;
|
||||
});
|
||||
}, [processos]);
|
||||
|
||||
// Função utilitária para pegar a cor de um processo
|
||||
const getCorProcesso = (nomeProcesso: string, index: number) => {
|
||||
const nomeLower = nomeProcesso.toLowerCase().trim();
|
||||
for (const [key, color] of Object.entries(CORES_PROCESSOS)) {
|
||||
if (nomeLower.includes(key)) return color;
|
||||
}
|
||||
return CORES_FALLBACK[index % CORES_FALLBACK.length];
|
||||
};
|
||||
|
||||
// 2. Processar dados agregados por data
|
||||
const dadosGrafico = useMemo(() => {
|
||||
if (!processosOrdenados || processosOrdenados.length === 0) return [];
|
||||
|
||||
// Coletar todas as datas dos gráficos individuais
|
||||
const todasAsDatas = new Set<string>();
|
||||
processos.forEach(processo => {
|
||||
processosOrdenados.forEach(processo => {
|
||||
processo.dadosGrafico.forEach(ponto => {
|
||||
todasAsDatas.add(ponto.data);
|
||||
});
|
||||
});
|
||||
|
||||
// Ordenar as datas
|
||||
const datasOrdenadas = Array.from(todasAsDatas).sort();
|
||||
const ultimosValoresKg: Record<string, number> = {};
|
||||
|
||||
// Construir dados do gráfico sobreposto
|
||||
return datasOrdenadas.map(data => {
|
||||
const pontoGrafico: any = {
|
||||
data: format(parseISO(data), 'dd/MM', { locale: ptBR }),
|
||||
const dataFormatada = format(parseISO(data), 'dd/MM', { locale: ptBR });
|
||||
const pontoGrafico: Record<string, string | number> = {
|
||||
data: dataFormatada,
|
||||
dataCompleta: data
|
||||
};
|
||||
|
||||
// Para cada processo, buscar o valor realizado na data
|
||||
processos.forEach(processo => {
|
||||
const pontoProcesso = processo.dadosGrafico.find(ponto => ponto.data === data);
|
||||
// Converter para toneladas (dividir por 1000)
|
||||
pontoGrafico[processo.nome] = pontoProcesso ? Math.round(pontoProcesso.realizado / 1000 * 100) / 100 : 0;
|
||||
processosOrdenados.forEach(processo => {
|
||||
const pontoProcesso = processo.dadosGrafico.find(p => p.data === data);
|
||||
|
||||
let valorKg: number;
|
||||
if (pontoProcesso && pontoProcesso.realizado > 0) {
|
||||
valorKg = pontoProcesso.realizado;
|
||||
ultimosValoresKg[processo.nome] = valorKg;
|
||||
} else {
|
||||
// Se não houver novo ponto ou for 0, mantém o acumulado anterior para a linha NUNCA cair
|
||||
valorKg = ultimosValoresKg[processo.nome] || 0;
|
||||
}
|
||||
|
||||
if (modoExibicao === 'percent') {
|
||||
const pesoTotal = processo.pesoTotal > 0 ? processo.pesoTotal : 1;
|
||||
const percentual = Math.min(100, (valorKg / pesoTotal) * 100);
|
||||
pontoGrafico[processo.nome] = Math.round(percentual * 10) / 10;
|
||||
} else {
|
||||
pontoGrafico[processo.nome] = Math.round(valorKg * 10) / 10;
|
||||
}
|
||||
});
|
||||
|
||||
return pontoGrafico;
|
||||
});
|
||||
}, [processos]);
|
||||
|
||||
// Obter cores dos processos baseado no status
|
||||
const obterCorProcesso = (status: string) => {
|
||||
switch (status) {
|
||||
case 'verde':
|
||||
return '#10b981';
|
||||
case 'amarelo':
|
||||
return '#f59e0b';
|
||||
case 'vermelho':
|
||||
return '#ef4444';
|
||||
case 'azul':
|
||||
return '#3b82f6';
|
||||
default:
|
||||
return '#8884d8';
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcessoClick = (processoNome: string) => {
|
||||
if (onProcessoClick) {
|
||||
onProcessoClick(processoNome);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTooltipValue = (value: number, name: string) => [
|
||||
`${value.toFixed(2)} t`,
|
||||
name
|
||||
];
|
||||
}, [processosOrdenados, modoExibicao]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-96">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={dadosGraficoSobreposto}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="data"
|
||||
stroke="#9ca3af"
|
||||
fontSize={12}
|
||||
tick={{ fill: '#9ca3af' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#9ca3af"
|
||||
fontSize={12}
|
||||
tick={{ fill: '#9ca3af' }}
|
||||
label={{
|
||||
value: 'Peso Acumulado (t)',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { textAnchor: 'middle', fill: '#9ca3af' }
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1f2937',
|
||||
border: '1px solid #374151',
|
||||
borderRadius: '6px',
|
||||
color: '#f3f4f6'
|
||||
}}
|
||||
formatter={formatTooltipValue}
|
||||
labelFormatter={(label) => `Data: ${label}`}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ color: '#9ca3af' }}
|
||||
onClick={(e) => handleProcessoClick(e.value)}
|
||||
/>
|
||||
<div className="space-y-4 w-full">
|
||||
{/* Barra Superior de Controles */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 bg-muted/40 p-2.5 rounded-lg border border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground hidden sm:inline">Métrica:</span>
|
||||
<div className="flex items-center bg-background border border-border rounded-md p-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant={modoExibicao === 'kg' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setModoExibicao('kg')}
|
||||
className="h-7 text-xs px-2.5 gap-1.5"
|
||||
>
|
||||
<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>
|
||||
|
||||
{processos.map((processo) => (
|
||||
<Line
|
||||
key={processo.nome}
|
||||
type="monotone"
|
||||
dataKey={processo.nome}
|
||||
stroke={obterCorProcesso(processo.status)}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: obterCorProcesso(processo.status), strokeWidth: 2, r: 3 }}
|
||||
activeDot={{ r: 5, fill: obterCorProcesso(processo.status) }}
|
||||
opacity={processoSelecionado ? (processoSelecionado === processo.nome ? 1 : 0.3) : 1}
|
||||
style={{ cursor: 'pointer' }}
|
||||
{selectedProcesso && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSelectProcesso(selectedProcesso)}
|
||||
className="h-7 text-xs gap-1 border-dashed"
|
||||
>
|
||||
<FilterX className="h-3.5 w-3.5" />
|
||||
<span>Limpar seleção ({selectedProcesso})</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legenda Customizada em Ordem Linear Sequencial */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 py-1">
|
||||
{processosOrdenados.map((processo, idx) => {
|
||||
const color = getCorProcesso(processo.nome, idx);
|
||||
const isSelected = selectedProcesso === processo.nome;
|
||||
const isDimmed = selectedProcesso && !isSelected;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={processo.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectProcesso(processo.nome)}
|
||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground border-primary font-semibold shadow-md scale-105'
|
||||
: isDimmed
|
||||
? 'bg-muted/30 text-muted-foreground border-transparent opacity-40 hover:opacity-70'
|
||||
: 'bg-muted/60 hover:bg-muted text-foreground border-border/60 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span>{processo.nome}</span>
|
||||
<span className="text-[10px] opacity-75 font-mono">
|
||||
({modoExibicao === 'kg' ? `${Math.round(processo.pesoFabricado)}kg` : `${processo.progressoReal.toFixed(0)}%`})
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Container de Altura Fixa com minWidth=0 para EVITAR Avisos no Console */}
|
||||
<div className="w-full h-[380px] min-h-[380px] relative">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
|
||||
<LineChart
|
||||
data={dadosGrafico}
|
||||
margin={{ top: 15, right: 25, left: 10, bottom: 25 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border/40" />
|
||||
<XAxis
|
||||
dataKey="data"
|
||||
stroke="currentColor"
|
||||
fontSize={11}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'currentColor', opacity: 0.2 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
fontSize={11}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'currentColor', opacity: 0.2 }}
|
||||
unit={modoExibicao === 'kg' ? ' kg' : '%'}
|
||||
tickFormatter={(val) => modoExibicao === 'kg' && val >= 1000 ? `${(val / 1000).toFixed(1)}k` : `${val}`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip modoExibicao={modoExibicao} selectedProcesso={selectedProcesso} />} />
|
||||
|
||||
{processosOrdenados.map((processo, idx) => {
|
||||
const color = getCorProcesso(processo.nome, idx);
|
||||
const isSelected = selectedProcesso === processo.nome;
|
||||
const isDimmed = selectedProcesso && !isSelected;
|
||||
|
||||
return (
|
||||
<Line
|
||||
key={processo.id}
|
||||
type="monotone"
|
||||
dataKey={processo.nome}
|
||||
name={processo.nome}
|
||||
stroke={color}
|
||||
strokeWidth={isSelected ? 3.5 : isDimmed ? 1 : 2.5}
|
||||
strokeOpacity={isDimmed ? 0.2 : 1}
|
||||
dot={isSelected ? { fill: color, r: 4.5, strokeWidth: 2, stroke: '#fff' } : false}
|
||||
activeDot={{ r: 6, fill: color, stroke: '#fff', strokeWidth: 2 }}
|
||||
connectNulls
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
|
||||
@@ -11,11 +10,16 @@ interface GraficoProgressoIndividualProps {
|
||||
|
||||
export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProps> = ({ processos }) => {
|
||||
const formatTooltipValue = (value: number, name: string) => [
|
||||
`${(value / 1000).toFixed(2)} t`,
|
||||
`${Math.round(value).toLocaleString('pt-BR')} kg`,
|
||||
name === 'planejado' ? 'Planejado' : 'Realizado'
|
||||
];
|
||||
|
||||
const formatAxisValue = (value: number) => `${(value / 1000).toFixed(1)}t`;
|
||||
const formatAxisValue = (value: number) => {
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}k kg`;
|
||||
}
|
||||
return `${Math.round(value)} kg`;
|
||||
};
|
||||
|
||||
const formatDateLabel = (tickItem: string) => {
|
||||
try {
|
||||
@@ -55,13 +59,13 @@ export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProp
|
||||
<h3 className="text-lg font-semibold text-card-foreground">
|
||||
Progresso - {processo.nome}
|
||||
</h3>
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
{processo.progressoReal.toFixed(1)}% realizado
|
||||
<div className="ml-auto text-sm text-muted-foreground font-mono">
|
||||
{processo.progressoReal.toFixed(1)}% realizado ({Math.round(processo.pesoFabricado)} kg)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<div className="h-64 w-full relative min-h-[256px]">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
|
||||
<AreaChart data={processo.dadosGrafico}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
|
||||
@@ -61,10 +61,10 @@ export const ResumoOF: React.FC<ResumoOFProps> = ({ of, data, loading }) => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-card-foreground">
|
||||
{(data.pesoTotalFabricado / 1000).toFixed(2)} t
|
||||
{data.pesoTotalFabricado.toFixed(2)} kg
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
de {(data.tonelagem / 1000).toFixed(2)} t contratadas
|
||||
de {data.tonelagem.toFixed(2)} kg contratadas
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const TabelaResumoProcessos: React.FC<TabelaResumoProcessosProps> = ({ da
|
||||
{getStatusBadge(processo.status)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-2">
|
||||
{(processo.pesoFabricado / 1000).toFixed(3)} t
|
||||
{processo.pesoFabricado.toFixed(1)} kg
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -19,14 +19,76 @@ export function UserInfo() {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loginTime, setLoginTime] = useState<string>('00:00:00');
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const loginStartTimeRef = useRef<number>(Date.now());
|
||||
const loginStartTimeRef = useRef<number>(0);
|
||||
|
||||
// Resetar tempo de login apenas em eventos reais de login
|
||||
function resetLoginTime() {
|
||||
// Limpar interval anterior se existir
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// Definir novo tempo de início
|
||||
const currentTime = Date.now();
|
||||
loginStartTimeRef.current = currentTime;
|
||||
localStorage.setItem('userLoginTime', currentTime.toString());
|
||||
|
||||
// Iniciar novo cronômetro
|
||||
intervalRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - loginStartTimeRef.current;
|
||||
const hours = Math.floor(elapsed / (1000 * 60 * 60));
|
||||
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
|
||||
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
|
||||
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchUserProfile() {
|
||||
if (!user) return;
|
||||
const { data: profileArr, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name, email, profile_image_url')
|
||||
.eq('id', user.id)
|
||||
.limit(1);
|
||||
|
||||
if (!error && profileArr && profileArr.length > 0) {
|
||||
setProfile(profileArr[0]);
|
||||
} else {
|
||||
setProfile({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
full_name: (user as any).name || (user as any).username || null,
|
||||
email: user.email || null,
|
||||
profile_image_url: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initializeLoginTime() {
|
||||
const savedLoginTime = localStorage.getItem('userLoginTime');
|
||||
if (savedLoginTime) {
|
||||
loginStartTimeRef.current = parseInt(savedLoginTime);
|
||||
} else {
|
||||
const currentTime = Date.now();
|
||||
loginStartTimeRef.current = currentTime;
|
||||
localStorage.setItem('userLoginTime', currentTime.toString());
|
||||
}
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
intervalRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - loginStartTimeRef.current;
|
||||
const hours = Math.floor(elapsed / (1000 * 60 * 60));
|
||||
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
|
||||
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
|
||||
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
fetchUserProfile();
|
||||
initializeLoginTime(); // Inicializar timer baseado no localStorage
|
||||
initializeLoginTime();
|
||||
} else {
|
||||
// Limpar timer quando não há usuário
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
@@ -46,97 +108,15 @@ export function UserInfo() {
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Inicializar tempo de login baseado no localStorage (preserva tempo entre navegações)
|
||||
const initializeLoginTime = () => {
|
||||
const savedLoginTime = localStorage.getItem('userLoginTime');
|
||||
|
||||
if (savedLoginTime) {
|
||||
// Usar tempo salvo do localStorage
|
||||
loginStartTimeRef.current = parseInt(savedLoginTime);
|
||||
} else {
|
||||
// Se não há tempo salvo, definir tempo atual (primeira vez)
|
||||
const currentTime = Date.now();
|
||||
loginStartTimeRef.current = currentTime;
|
||||
localStorage.setItem('userLoginTime', currentTime.toString());
|
||||
}
|
||||
|
||||
// Limpar interval anterior se existir
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// Iniciar cronômetro
|
||||
intervalRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - loginStartTimeRef.current;
|
||||
const hours = Math.floor(elapsed / (1000 * 60 * 60));
|
||||
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
|
||||
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
|
||||
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Resetar tempo de login apenas em eventos reais de login
|
||||
const resetLoginTime = () => {
|
||||
// Limpar interval anterior se existir
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// Definir novo tempo de início
|
||||
const currentTime = Date.now();
|
||||
loginStartTimeRef.current = currentTime;
|
||||
localStorage.setItem('userLoginTime', currentTime.toString());
|
||||
|
||||
// Iniciar novo cronômetro
|
||||
intervalRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - loginStartTimeRef.current;
|
||||
const hours = Math.floor(elapsed / (1000 * 60 * 60));
|
||||
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
|
||||
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
|
||||
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Limpar tempo de login quando usuário sair
|
||||
// Limpe o interval quando desmontado
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
localStorage.removeItem('userLoginTime');
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name, email, profile_image_url')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (!error && data) {
|
||||
setProfile(data);
|
||||
} else {
|
||||
// Fallback para dados do usuário auth
|
||||
setProfile({
|
||||
full_name: user.user_metadata?.full_name || null,
|
||||
email: user.email || null,
|
||||
profile_image_url: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!user || !profile) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -104,8 +104,8 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
peca_id,
|
||||
quantidade_produzida,
|
||||
processo_id,
|
||||
processos_fabricacao!inner(nome),
|
||||
pecas!inner(
|
||||
processo:processos_fabricacao!inner!apontamentos_producao_processo_id_fkey(nome),
|
||||
peca:pecas!inner!apontamentos_producao_peca_id_fkey(
|
||||
id,
|
||||
marca,
|
||||
descricao,
|
||||
@@ -115,43 +115,80 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
of_number
|
||||
)
|
||||
`)
|
||||
.eq('pecas.of_number', romaneio.of_number)
|
||||
.eq('processos_fabricacao.nome', processo);
|
||||
.eq('peca.of_number', romaneio.of_number)
|
||||
.eq('processo.nome', processo);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao buscar peças disponíveis:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Agrupar por peça e calcular quantidade disponível
|
||||
// 2. Buscar quanto dessa OF já foi embalado em TODOS os romaneios da OF
|
||||
const { data: expedidosData, error: expError } = await supabase
|
||||
.from('itens_romaneio_pecas')
|
||||
.select(`
|
||||
peca_id,
|
||||
quantidade_expedida,
|
||||
romaneio:romaneios_expedicao!inner!itens_romaneio_pecas_romaneio_id_fkey(of_number)
|
||||
`)
|
||||
.eq('romaneio.of_number', romaneio.of_number);
|
||||
|
||||
const expedidosMap = new Map<string, number>();
|
||||
if (!expError && expedidosData) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(expedidosData as any[]).forEach((item: any) => {
|
||||
const pid = item.peca_id;
|
||||
const qtd = item.quantidade_expedida || 0;
|
||||
const current = expedidosMap.get(pid) || 0;
|
||||
expedidosMap.set(pid, current + qtd);
|
||||
});
|
||||
}
|
||||
|
||||
// Agrupar por peça e calcular quantidade total produzida
|
||||
const pecasAgrupadas = new Map<string, PecaDisponivel>();
|
||||
|
||||
apontamentosData?.forEach(apontamento => {
|
||||
const pecaId = apontamento.pecas.id;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(apontamentosData as any[])?.forEach((apontamento: any) => {
|
||||
const pecaId = apontamento.peca?.id;
|
||||
if (!pecaId) return;
|
||||
|
||||
const quantidade = apontamento.quantidade_produzida || 0;
|
||||
|
||||
if (pecasAgrupadas.has(pecaId)) {
|
||||
const pecaExistente = pecasAgrupadas.get(pecaId)!;
|
||||
pecaExistente.quantidade_disponivel += quantidade;
|
||||
pecaExistente.quantidade_disponivel += quantidade; // Aqui salva o Bruto temporariamente
|
||||
} else {
|
||||
pecasAgrupadas.set(pecaId, {
|
||||
id: apontamento.pecas.id,
|
||||
marca: apontamento.pecas.marca,
|
||||
descricao: apontamento.pecas.descricao || '',
|
||||
etapa_fase: apontamento.pecas.etapa_fase || '',
|
||||
quantidade_disponivel: quantidade,
|
||||
peso_unitario: apontamento.pecas.peso_unitario || 0,
|
||||
prioridade: apontamento.pecas.prioridade || 'P4'
|
||||
id: apontamento.peca.id,
|
||||
marca: apontamento.peca.marca,
|
||||
descricao: apontamento.peca.descricao || '',
|
||||
etapa_fase: apontamento.peca.etapa_fase || '',
|
||||
quantidade_disponivel: quantidade, // Bruto temporário
|
||||
peso_unitario: apontamento.peca.peso_unitario || 0,
|
||||
prioridade: apontamento.peca.prioridade || 'P4'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const pecasArray = Array.from(pecasAgrupadas.values());
|
||||
setPecasDisponiveis(pecasArray);
|
||||
// 4. Calcular o saldo Real (Produzido - Expedido) e limpar zerados
|
||||
const pecasDisponiveisFinal: PecaDisponivel[] = [];
|
||||
pecasAgrupadas.forEach((peca, pecaId) => {
|
||||
const qtdExpedida = expedidosMap.get(pecaId) || 0;
|
||||
const saldoReal = peca.quantidade_disponivel - qtdExpedida;
|
||||
|
||||
if (saldoReal > 0) {
|
||||
pecasDisponiveisFinal.push({
|
||||
...peca,
|
||||
quantidade_disponivel: saldoReal // Retorna apenas o saldo disponível
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setPecasDisponiveis(pecasDisponiveisFinal);
|
||||
|
||||
// Extrair fases e marcas únicas
|
||||
const fasesUnicas = [...new Set(pecasArray.map(p => p.etapa_fase).filter(Boolean))];
|
||||
const marcasUnicas = [...new Set(pecasArray.map(p => p.marca).filter(Boolean))];
|
||||
const fasesUnicas = [...new Set(pecasDisponiveisFinal.map(p => p.etapa_fase).filter(Boolean))];
|
||||
const marcasUnicas = [...new Set(pecasDisponiveisFinal.map(p => p.marca).filter(Boolean))];
|
||||
|
||||
setFasesDisponiveis(fasesUnicas);
|
||||
setMarcasDisponiveis(marcasUnicas);
|
||||
@@ -180,6 +217,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
if (pecasError) {
|
||||
console.error('Erro ao carregar peças do romaneio:', pecasError);
|
||||
} else if (pecasData) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const itensPecaFormatados = pecasData.map((item: any) => ({
|
||||
id: item.id,
|
||||
peca_id: item.peca_id,
|
||||
@@ -203,6 +241,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
if (insumosError) {
|
||||
console.error('Erro ao carregar insumos do romaneio:', insumosError);
|
||||
} else if (insumosData) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const itensInsumoFormatados = insumosData.map((item: any) => ({
|
||||
id: item.id,
|
||||
nome: item.descricao,
|
||||
@@ -225,6 +264,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
if (processoSelecionado && isOpen) {
|
||||
buscarPecasDisponiveis(processoSelecionado);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [processoSelecionado, isOpen, romaneio.of_number]);
|
||||
|
||||
// Definir processo padrão quando abrir o modal
|
||||
@@ -238,6 +278,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
setAdicionarTodas(false);
|
||||
carregarItensRomaneio();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
// Filtrar peças baseado nos filtros selecionados
|
||||
@@ -306,6 +347,12 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
|
||||
try {
|
||||
const quantidade = parseInt(quantidadePeca);
|
||||
|
||||
if (quantidade > pecaSelecionada.quantidade_disponivel) {
|
||||
toast.error(`Quantidade não autorizada! Saldo disponível é ${pecaSelecionada.quantidade_disponivel}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('itens_romaneio_pecas')
|
||||
.insert({
|
||||
@@ -329,6 +376,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
|
||||
// Recarregar itens e limpar formulário
|
||||
carregarItensRomaneio();
|
||||
if (processoSelecionado) {
|
||||
buscarPecasDisponiveis(processoSelecionado);
|
||||
}
|
||||
setPecaDisponivel('');
|
||||
setQuantidadePeca('');
|
||||
setAdicionarTodas(false);
|
||||
@@ -374,6 +424,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
if (error) throw error;
|
||||
toast.success('Item removido do romaneio');
|
||||
carregarItensRomaneio();
|
||||
if (processoSelecionado) {
|
||||
buscarPecasDisponiveis(processoSelecionado);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao remover item:', error);
|
||||
toast.error('Erro ao remover item do romaneio');
|
||||
@@ -572,6 +625,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={pecasDisponiveis.find(p => p.id === pecaDisponivel)?.quantidade_disponivel || 1}
|
||||
value={quantidadePeca}
|
||||
onChange={(e) => setQuantidadePeca(e.target.value)}
|
||||
placeholder="Quantidade"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,163 +53,69 @@ export const ProcessChartOptimized: React.FC<ProcessChartOptimizedProps> = ({
|
||||
const normalizedMontagemData = normalizeData(montagemData);
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'Inter, sans-serif' }} className="h-full">
|
||||
<div style={{ fontFamily: 'Inter, sans-serif' }} className="h-full flex flex-col pt-2 pb-3">
|
||||
{/* Legenda Global */}
|
||||
<div className="flex justify-center gap-4 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 bg-[#f97316] rounded-sm shadow-sm"></div>
|
||||
<span className="font-medium">7 a 15 dias</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 bg-[#10b981] rounded-sm shadow-sm"></div>
|
||||
<span className="font-medium">Últimos 7 dias</span>
|
||||
</div>
|
||||
<div 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>
|
||||
|
||||
<div
|
||||
className="flex justify-evenly items-end h-full p-4 pt-6 pb-6 rounded-lg"
|
||||
style={{ height: '200px' }}
|
||||
className="flex justify-evenly items-end h-full px-2"
|
||||
style={{ minHeight: '130px' }}
|
||||
>
|
||||
{/* Grupo Corte */}
|
||||
<div className="flex flex-col justify-end items-center h-full flex-1">
|
||||
<div
|
||||
className="flex items-end justify-center w-full gap-1"
|
||||
style={{ height: '120px' }}
|
||||
>
|
||||
{normalizedCorteData.map((data, index) => {
|
||||
const colors = ['#f97316', '#10b981', '#ec4899']; // orange, green, pink
|
||||
const labels = ['7-15d', 'ult.7d', 'prev.7d'];
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="relative transition-all duration-500"
|
||||
style={{
|
||||
width: '20px',
|
||||
height: `${calculateHeight(data.weight)}%`,
|
||||
backgroundColor: colors[index],
|
||||
borderRadius: '4px 4px 0 0'
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ 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
|
||||
return (
|
||||
<div
|
||||
className="absolute w-full text-center text-xs font-semibold text-foreground"
|
||||
key={index}
|
||||
className="relative transition-all duration-500 shadow-sm"
|
||||
style={{
|
||||
top: '-20px',
|
||||
fontSize: '10px',
|
||||
lineHeight: '1.2'
|
||||
width: '24px',
|
||||
height: `${calculateHeight(data.weight)}%`,
|
||||
backgroundColor: colors[index],
|
||||
borderRadius: '4px 4px 0 0'
|
||||
}}
|
||||
>
|
||||
{formatValue(data.weight)}
|
||||
<div
|
||||
className="absolute w-full text-center font-bold text-foreground"
|
||||
style={{
|
||||
top: '-22px',
|
||||
fontSize: '11px',
|
||||
lineHeight: '1.2'
|
||||
}}
|
||||
>
|
||||
{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 className="mt-4 text-foreground font-semibold text-xs sm:text-sm border-t border-border pt-2 w-4/5 text-center">
|
||||
{group.title}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-4 text-muted-foreground font-medium"
|
||||
style={{ fontSize: '11px' }}
|
||||
>
|
||||
Corte
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grupo Solda */}
|
||||
<div className="flex flex-col justify-end items-center h-full flex-1">
|
||||
<div
|
||||
className="flex items-end justify-center w-full gap-1"
|
||||
style={{ height: '120px' }}
|
||||
>
|
||||
{normalizedSoldaData.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' }}
|
||||
>
|
||||
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>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -123,12 +123,7 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
|
||||
});
|
||||
convertedData[field] = 0;
|
||||
} else {
|
||||
// Para peso_unitario e peso_total, arredondar para integer
|
||||
if (field === 'peso_unitario' || field === 'peso_total') {
|
||||
convertedData[field] = Math.round(numValue);
|
||||
} else {
|
||||
convertedData[field] = numValue;
|
||||
}
|
||||
convertedData[field] = numValue;
|
||||
}
|
||||
} else {
|
||||
convertedData[field] = field.includes('componente') ? 0 : (field === 'quantidade' ? 1 : 0);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,13 +6,15 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Upload, FileText, Trash2, Download, Undo2 } from 'lucide-react';
|
||||
import { Upload, FileText, Trash2, Download, Undo2, FileSpreadsheet } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Peca } from '@/hooks/usePecas';
|
||||
import { ImportarPecasModal } from './ImportarPecasModal';
|
||||
import { ImportarXLSModal } from './ImportarXLSModal';
|
||||
|
||||
interface PecaFormProps {
|
||||
ofNumbers: string[];
|
||||
ofDefault?: string;
|
||||
onSave: (data: any) => Promise<boolean>;
|
||||
onUpdate: (id: string, data: any) => Promise<boolean>;
|
||||
onImportCSV: (file: File) => Promise<boolean>;
|
||||
@@ -30,6 +32,7 @@ interface PecaFormProps {
|
||||
|
||||
export function PecaForm({
|
||||
ofNumbers,
|
||||
ofDefault,
|
||||
onSave,
|
||||
onUpdate,
|
||||
onImportCSV,
|
||||
@@ -92,6 +95,7 @@ export function PecaForm({
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showXLSModal, setShowXLSModal] = useState(false);
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -361,6 +365,16 @@ export function PecaForm({
|
||||
<Download className="h-4 w-4" />
|
||||
Download Modelo CSV
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowXLSModal(true)}
|
||||
className="flex items-center gap-2 border-emerald-600 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30 font-semibold"
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 text-emerald-600" />
|
||||
Importar de XLS
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -385,6 +399,13 @@ export function PecaForm({
|
||||
etapa_fase: peca.etapa_fase
|
||||
}))}
|
||||
/>
|
||||
|
||||
<ImportarXLSModal
|
||||
open={showXLSModal}
|
||||
onOpenChange={setShowXLSModal}
|
||||
onImport={onImportPecas}
|
||||
ofDefault={formData.of_number || ofDefault || (ofNumbers && ofNumbers[0]) || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function TableRow({
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-16 px-2 text-right font-medium text-foreground text-xs">
|
||||
{(peca.quantidade * peca.peso_unitario).toFixed(2)} kg
|
||||
{(peca.peso_total || (peca.quantidade * peca.peso_unitario)).toFixed(2)} kg
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-16 px-2 text-muted-foreground text-xs">
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PrioridadesPDFTemplate } from './PrioridadesPDFTemplate';
|
||||
import { ItemPrioridade } from '@/hooks/useItensPrioridadeFabricacao';
|
||||
import { Download } from 'lucide-react';
|
||||
import html2canvas from 'html2canvas';
|
||||
import jsPDF from 'jspdf';
|
||||
import { Download, Loader2, Printer } from 'lucide-react';
|
||||
import { generateProfessionalPDF, printProfessionalPDF } from '@/utils/pdfGenerator';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface PrioridadesPDFProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
itensPorPrioridade: { [key: string]: ItemPrioridade[] };
|
||||
ofSelecionada?: string | null;
|
||||
faseSelecionada?: string | null;
|
||||
versaoAtual?: {
|
||||
revisao: number;
|
||||
dataModificacao: string;
|
||||
@@ -23,64 +25,66 @@ export const PrioridadesPDF: React.FC<PrioridadesPDFProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
itensPorPrioridade,
|
||||
ofSelecionada,
|
||||
faseSelecionada,
|
||||
versaoAtual
|
||||
}) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
const getNomeArquivo = () => {
|
||||
let nomeArquivo = 'checklist-producao';
|
||||
const todosItens = Object.values(itensPorPrioridade).flat();
|
||||
const primeiroItem = todosItens[0];
|
||||
const of = ofSelecionada || primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number;
|
||||
const fase = faseSelecionada || primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase;
|
||||
|
||||
if (of && fase) {
|
||||
nomeArquivo = `checklist-producao-${of}-${fase}`;
|
||||
if (versaoAtual) {
|
||||
nomeArquivo += `-rev${versaoAtual.revisao}`;
|
||||
}
|
||||
}
|
||||
return nomeArquivo;
|
||||
};
|
||||
|
||||
const handleGerarPDF = async () => {
|
||||
const elemento = document.getElementById('prioridades-pdf-content');
|
||||
|
||||
if (!elemento) {
|
||||
console.error('Elemento não encontrado');
|
||||
toast.error('Elemento do relatório 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';
|
||||
|
||||
if (todosItens.length > 0) {
|
||||
const primeiroItem = todosItens[0];
|
||||
const of = primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number;
|
||||
const fase = primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase;
|
||||
|
||||
if (of && fase) {
|
||||
nomeArquivo = `checklist-producao-${of}-${fase}`;
|
||||
if (versaoAtual) {
|
||||
nomeArquivo += `-rev${versaoAtual.revisao}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pdf.save(`${nomeArquivo}.pdf`);
|
||||
} 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);
|
||||
toast.error(error.message || 'Erro ao gerar PDF');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImprimir = async () => {
|
||||
const elemento = document.getElementById('prioridades-pdf-content');
|
||||
if (!elemento) {
|
||||
toast.error('Elemento do relatório não encontrado');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPrinting(true);
|
||||
await printProfessionalPDF('prioridades-pdf-content');
|
||||
toast.success('Relatório enviado para impressão');
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao imprimir:', error);
|
||||
toast.error('Erro ao imprimir relatório');
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,18 +92,53 @@ export const PrioridadesPDF: React.FC<PrioridadesPDFProps> = ({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
Visualizar Checklist de Produção
|
||||
<Button onClick={handleGerarPDF} className="ml-4">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Baixar PDF
|
||||
</Button>
|
||||
<DialogTitle className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span>Visualizar Checklist de Produção</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImprimir}
|
||||
disabled={isPrinting || isGenerating}
|
||||
>
|
||||
{isPrinting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Imprimindo...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
Imprimir
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGerarPDF}
|
||||
disabled={isGenerating || isPrinting}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Gerando PDF...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Baixar PDF
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4">
|
||||
<PrioridadesPDFTemplate
|
||||
itensPorPrioridade={itensPorPrioridade}
|
||||
ofSelecionada={ofSelecionada}
|
||||
faseSelecionada={faseSelecionada}
|
||||
versaoAtual={versaoAtual}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ItemPrioridade } from '@/hooks/useItensPrioridadeFabricacao';
|
||||
|
||||
interface PrioridadesPDFTemplateProps {
|
||||
itensPorPrioridade: { [key: string]: ItemPrioridade[] };
|
||||
ofSelecionada?: string | null;
|
||||
faseSelecionada?: string | null;
|
||||
versaoAtual?: {
|
||||
revisao: number;
|
||||
dataModificacao: string;
|
||||
@@ -13,13 +15,15 @@ interface PrioridadesPDFTemplateProps {
|
||||
|
||||
export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
||||
itensPorPrioridade,
|
||||
ofSelecionada,
|
||||
faseSelecionada,
|
||||
versaoAtual
|
||||
}) => {
|
||||
const todosItens = Object.values(itensPorPrioridade).flat();
|
||||
|
||||
const primeiroItem = todosItens[0];
|
||||
const ofNumber = primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number || 'N/A';
|
||||
const etapaFase = primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase || 'N/A';
|
||||
const ofNumber = ofSelecionada || primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number || 'N/A';
|
||||
const etapaFase = faseSelecionada || primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase || 'N/A';
|
||||
|
||||
const dataAtual = new Date().toLocaleDateString('pt-BR');
|
||||
|
||||
@@ -35,11 +39,11 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
||||
|
||||
const getCoresPrioridade = (codigo: string) => {
|
||||
switch (codigo) {
|
||||
case 'P1': return 'text-red-700 bg-red-100';
|
||||
case 'P2': return 'text-orange-700 bg-orange-100';
|
||||
case 'P3': return 'text-blue-700 bg-blue-100';
|
||||
case 'P4': return 'text-gray-700 bg-gray-200';
|
||||
default: return 'text-gray-700 bg-gray-200';
|
||||
case 'P1': return 'text-red-700 bg-red-100 border-red-300';
|
||||
case 'P2': return 'text-orange-700 bg-orange-100 border-orange-300';
|
||||
case 'P3': return 'text-blue-700 bg-blue-100 border-blue-300';
|
||||
case 'P4': return 'text-gray-700 bg-gray-200 border-gray-300';
|
||||
default: return 'text-gray-700 bg-gray-200 border-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,66 +54,83 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
||||
const numBigBoxes = Math.floor(quantity / 5);
|
||||
const numSmallBoxes = quantity % 5;
|
||||
|
||||
// Quadrados grandes com "5"
|
||||
// Quadrados com "5"
|
||||
for (let i = 0; i < numBigBoxes; i++) {
|
||||
boxes.push(
|
||||
<div key={`big-${i}`} className="tick-box-large">
|
||||
<span>5</span>
|
||||
</div>
|
||||
<svg
|
||||
key={`big-${i}`}
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 13 13"
|
||||
style={{ display: 'inline-block', verticalAlign: '-1px', marginRight: '2px' }}
|
||||
>
|
||||
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#f3f4f6" stroke="#4b5563" strokeWidth="1" />
|
||||
<text x="6.5" y="9.5" textAnchor="middle" fontSize="8.5" fontFamily="Arial, sans-serif" fontWeight="bold" fill="#4b5563">5</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Quadrados pequenos restantes
|
||||
// Quadrados unitários restantes
|
||||
for (let i = 0; i < numSmallBoxes; i++) {
|
||||
boxes.push(<div key={`small-${i}`} className="tick-box"></div>);
|
||||
boxes.push(
|
||||
<svg
|
||||
key={`small-${i}`}
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 13 13"
|
||||
style={{ display: 'inline-block', verticalAlign: '-1px', marginRight: '2px' }}
|
||||
>
|
||||
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Apenas quadrados pequenos
|
||||
// Apenas quadrados unitários
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
boxes.push(<div key={i} className="tick-box"></div>);
|
||||
boxes.push(
|
||||
<svg
|
||||
key={i}
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 13 13"
|
||||
style={{ display: 'inline-block', verticalAlign: '-1px', marginRight: '2px' }}
|
||||
>
|
||||
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="flex items-center flex-wrap gap-1">{boxes}</div>;
|
||||
return <span style={{ display: 'inline-block', verticalAlign: 'middle', marginLeft: '4px' }}>{boxes}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="prioridades-pdf-content" className="bg-white text-black max-w-4xl mx-auto p-6">
|
||||
<style>{`
|
||||
.tick-box {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1px solid #6b7280;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tick-box-large {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #6b7280;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tick-box-large span {
|
||||
color: #d1d5db;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
.checklist-container {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #111827;
|
||||
}
|
||||
.item-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.item-signature {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: #4b5563;
|
||||
border-bottom: 1px solid #9ca3af;
|
||||
padding-bottom: 2px;
|
||||
height: 18px;
|
||||
line-height: 14px;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
font-size: 9px;
|
||||
}
|
||||
.check-box-print {
|
||||
border: 1px solid #333 !important;
|
||||
}
|
||||
.page-break {
|
||||
page-break-before: always;
|
||||
}
|
||||
@@ -122,114 +143,142 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Cabeçalho do Relatório */}
|
||||
<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 className="checklist-container">
|
||||
{/* Cabeçalho do Relatório */}
|
||||
<div className="flex justify-between items-center border-b-2 border-gray-800 pb-3 mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 leading-tight">Checklist de Produção</h1>
|
||||
<p className="text-xs text-gray-600">Formulário para apontamento da fabricação.</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-sm">
|
||||
Data de Emissão: <span className="font-normal">{dataAtual}</span>
|
||||
{versaoAtual && (
|
||||
<span className="ml-2 text-gray-500 font-medium">Rev. {versaoAtual.revisao}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">
|
||||
Data de Emissão: <span className="font-normal">{dataAtual}</span>
|
||||
{versaoAtual && (
|
||||
<span className="ml-2 text-gray-500">Rev. {versaoAtual.revisao}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informações da OF e Fase */}
|
||||
<div className="border border-gray-200 bg-white p-4 rounded-lg mb-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||
{/* Coluna OF */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
|
||||
<p className="text-base font-bold text-gray-800">{ofNumber}</p>
|
||||
</div>
|
||||
{/* Coluna Fase */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Fase</p>
|
||||
<p className="text-base font-bold text-gray-800">{etapaFase}</p>
|
||||
</div>
|
||||
{/* Coluna Processo */}
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs font-medium text-gray-500">PROCESSO</p>
|
||||
<div className="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1">
|
||||
{['Corte', 'Solda', 'Pintura', 'Expedição'].map((processo) => (
|
||||
<div key={processo} className="flex items-center gap-1">
|
||||
<div className="w-4 h-4 border-2 border-gray-500 check-box-print"></div>
|
||||
<span className="text-sm font-semibold text-gray-700">{processo}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Informações da OF e Fase */}
|
||||
<div className="border border-gray-200 bg-white p-3.5 rounded-lg mb-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-3">
|
||||
{/* Coluna OF */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
|
||||
<p className="text-base font-bold text-gray-800 leading-snug">{ofNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legenda */}
|
||||
<div className="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3">
|
||||
<span className="font-semibold">Legenda:</span>
|
||||
<span>Marca (Qtd)</span>
|
||||
<span className="font-medium text-gray-500">(S/M)</span>
|
||||
<span>= Sem Montagem,</span>
|
||||
<span className="font-medium text-gray-500">(C/M)</span>
|
||||
<span>= Com Montagem. Os quadrados</span>
|
||||
<div className="tick-box inline-block"></div>
|
||||
<span>indicam o controle de peças fabricadas.</span>
|
||||
</div>
|
||||
|
||||
{/* 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;
|
||||
|
||||
return (
|
||||
<div key={codigo} className={priorityIndex > 0 ? 'page-break' : ''}>
|
||||
<h2 className={`text-lg font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-3`}>
|
||||
{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)';
|
||||
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{/* Coluna Fase */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Fase</p>
|
||||
<p className="text-base font-bold text-gray-800 leading-snug">{etapaFase}</p>
|
||||
</div>
|
||||
{/* Coluna Processo */}
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">PROCESSO</p>
|
||||
<div style={{ marginTop: '2px' }}>
|
||||
{['Corte', 'Solda', 'Pintura', 'Expedição'].map((processo) => (
|
||||
<div
|
||||
key={processo}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
marginRight: '16px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
style={{ display: 'inline-block', verticalAlign: '-2px', marginRight: '4px' }}
|
||||
>
|
||||
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1.5" />
|
||||
</svg>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: '#374151', verticalAlign: 'middle' }}>
|
||||
{processo}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legenda */}
|
||||
<div className="text-xs text-gray-600 mb-5 flex items-center flex-wrap">
|
||||
<span className="font-semibold" style={{ marginRight: '6px' }}>Legenda:</span>
|
||||
<span style={{ marginRight: '6px' }}>Marca (Qtd)</span>
|
||||
<span className="font-medium text-gray-500" style={{ marginRight: '4px' }}>(S/M)</span>
|
||||
<span style={{ marginRight: '6px' }}>= Sem Montagem,</span>
|
||||
<span className="font-medium text-gray-500" style={{ marginRight: '4px' }}>(C/M)</span>
|
||||
<span style={{ marginRight: '4px' }}>= Com Montagem. Os quadrados</span>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 13 13"
|
||||
style={{ display: 'inline-block', verticalAlign: '-2px', margin: '0 4px' }}
|
||||
>
|
||||
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1" />
|
||||
</svg>
|
||||
<span>indicam o controle de peças fabricadas.</span>
|
||||
</div>
|
||||
|
||||
{/* Itens por Prioridade */}
|
||||
<div className="space-y-6">
|
||||
{['P1', 'P2', 'P3', 'P4'].map((codigo, priorityIndex) => {
|
||||
const itens = itensPorPrioridade[codigo] || [];
|
||||
if (itens.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={codigo} className={priorityIndex > 0 ? 'page-break' : ''}>
|
||||
<h2 className={`text-base font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-2.5 border`}>
|
||||
{getPrioridadeNome(codigo)}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{Array.from({ length: Math.ceil(itens.length / 3) }, (_, i) => {
|
||||
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50/70' : '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)';
|
||||
|
||||
return (
|
||||
<div key={item.id} className="item-card">
|
||||
<div style={{ marginBottom: '6px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: '#111827', verticalAlign: 'middle', marginRight: '4px' }}>
|
||||
{marca} ({quantidade})
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: '#6b7280', verticalAlign: 'middle' }}>
|
||||
{infoType}
|
||||
</span>
|
||||
{generateTickBoxes(quantidade)}
|
||||
</div>
|
||||
<div className="item-signature">
|
||||
Data/Operador:
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -78,22 +78,26 @@ export const ApontamentoDiarioChart = () => {
|
||||
return { valorMaximo, linhasGrid: linhas };
|
||||
}, [dadosProcessados]);
|
||||
|
||||
// Cores para cada OF
|
||||
const coresOF = {
|
||||
'B114': 'bg-blue-500',
|
||||
'B117': 'bg-amber-500',
|
||||
'B118': 'bg-emerald-500',
|
||||
'B119': 'bg-purple-500',
|
||||
'B120': 'bg-pink-500',
|
||||
'B121': 'bg-indigo-500',
|
||||
'B122': 'bg-cyan-500',
|
||||
'B123': 'bg-teal-500',
|
||||
'B124': 'bg-lime-500',
|
||||
'B125': 'bg-red-500',
|
||||
};
|
||||
// Array de cores profissionais e vibrantes para as OFs
|
||||
const availableColors = [
|
||||
'bg-blue-500',
|
||||
'bg-emerald-500',
|
||||
'bg-violet-500',
|
||||
'bg-amber-500',
|
||||
'bg-rose-500',
|
||||
'bg-cyan-500',
|
||||
'bg-fuchsia-500',
|
||||
'bg-lime-500',
|
||||
'bg-indigo-500',
|
||||
'bg-orange-500',
|
||||
'bg-teal-500',
|
||||
'bg-pink-500'
|
||||
];
|
||||
|
||||
const getCorOF = (ofNumber: string) => {
|
||||
return coresOF[ofNumber as keyof typeof coresOF] || 'bg-gray-500';
|
||||
const index = ofsUnicas.indexOf(ofNumber);
|
||||
if (index === -1) return 'bg-gray-500';
|
||||
return availableColors[index % availableColors.length];
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
@@ -222,19 +226,19 @@ export const ApontamentoDiarioChart = () => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Legenda interativa */}
|
||||
<div className="flex justify-center flex-wrap gap-4 mb-8">
|
||||
<div className="flex justify-center flex-wrap gap-3 mb-8">
|
||||
{ofsUnicas.map((ofNumber) => (
|
||||
<button
|
||||
key={ofNumber}
|
||||
onClick={() => setSelectedOF(selectedOF === ofNumber ? null : ofNumber)}
|
||||
className={`flex items-center gap-2 px-3 py-1 rounded-md transition-all ${
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-full border shadow-sm transition-all duration-200 ${
|
||||
selectedOF === ofNumber
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'
|
||||
? 'bg-primary text-primary-foreground border-primary scale-105 shadow-md'
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded-sm ${getCorOF(ofNumber)}`} />
|
||||
<span className="text-sm font-medium">OF {ofNumber}</span>
|
||||
<div className={`w-3 h-3 rounded-full shadow-inner ${getCorOF(ofNumber)}`} />
|
||||
<span className="text-sm font-semibold">OF {ofNumber}</span>
|
||||
</button>
|
||||
))}
|
||||
{selectedOF && (
|
||||
@@ -262,7 +266,7 @@ export const ApontamentoDiarioChart = () => {
|
||||
style={{ bottom: `${linha.porcentagem}%`, transform: 'translateY(50%)' }}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground mr-2">
|
||||
{linha.valor >= 1000 ? `${(linha.valor/1000).toFixed(0)}t` : `${linha.valor}kg`}
|
||||
{Math.round(linha.valor).toLocaleString('pt-BR')} Kg
|
||||
</span>
|
||||
<div className="w-2 h-px bg-border"></div>
|
||||
</div>
|
||||
@@ -315,7 +319,7 @@ export const ApontamentoDiarioChart = () => {
|
||||
>
|
||||
{alturaPixels > 20 && (
|
||||
<span className="transform rotate-90 whitespace-nowrap">
|
||||
{ofData.peso > 999 ? `${Math.round(ofData.peso/1000)}t` : `${ofData.peso}kg`}
|
||||
{Math.round(ofData.peso).toLocaleString('pt-BR')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -47,14 +47,8 @@ export const RelatorioPecasProcessoModal: React.FC<RelatorioPecasProcessoModalPr
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between text-card-foreground">
|
||||
<span>Relatório de Peças por Processo</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<DialogTitle className="text-card-foreground">
|
||||
Relatório de Peças por Processo
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -33,37 +33,50 @@ export const RelatorioPecasProcessoPDF: React.FC<RelatorioPecasProcessoPDFProps>
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = new jsPDF('l', 'mm', 'a4'); // Landscape orientation
|
||||
const doc = new jsPDF('p', 'mm', 'a4'); // Portrait orientation para otimizar espaço
|
||||
|
||||
const faseText = selectedFase === 'todas' ? '' : ` - Fase ${selectedFase}`;
|
||||
|
||||
// Título
|
||||
doc.setFontSize(20);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Relatório de Status', 20, 20);
|
||||
|
||||
// Subtítulo
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Ordem de Fabricação: ${selectedOF}${faseText}`, 20, 30);
|
||||
|
||||
// Data/hora
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 20, 40);
|
||||
|
||||
// Resumo por Processo
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Resumo por Processo', 20, 55);
|
||||
doc.text('Relatório de Status de Produção', 14, 16);
|
||||
|
||||
// Subtítulo e Data
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
let yPos = 65;
|
||||
doc.text(`Total de Peças: ${estatisticas.totalPecas}`, 20, yPos);
|
||||
doc.text(`Peso Corte: ${estatisticas.pesoTotalCorte.toFixed(2)} kg`, 80, yPos);
|
||||
doc.text(`Peso Solda: ${estatisticas.pesoTotalSolda.toFixed(2)} kg`, 140, yPos);
|
||||
doc.text(`Peso Pintura: ${estatisticas.pesoTotalPintura.toFixed(2)} kg`, 200, yPos);
|
||||
doc.text(`Peso Expedição: ${estatisticas.pesoTotalExpedicao.toFixed(2)} kg`, 260, yPos);
|
||||
doc.text(`OF: ${selectedOF}${faseText} | Gerado em: ${new Date().toLocaleString('pt-BR')}`, 14, 23);
|
||||
|
||||
// Resumo por Processo em linha mais compacta
|
||||
let yPos = 32;
|
||||
doc.setFontSize(9);
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Total Peças:`, 14, yPos);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`${estatisticas.totalPecas}`, 36, yPos);
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Corte:`, 50, yPos);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`${estatisticas.pesoTotalCorte.toFixed(0)} kg`, 62, yPos);
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Solda:`, 90, yPos);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`${estatisticas.pesoTotalSolda.toFixed(0)} kg`, 102, yPos);
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Pintura:`, 130, yPos);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`${estatisticas.pesoTotalPintura.toFixed(0)} kg`, 145, yPos);
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Expedição:`, 175, yPos);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`${estatisticas.pesoTotalExpedicao.toFixed(0)} kg`, 192, yPos);
|
||||
|
||||
yPos = 38; // Ajusta yPos para o início da tabela
|
||||
|
||||
// Preparar dados para a tabela
|
||||
const tableData = pecasComStatus.map(peca => [
|
||||
@@ -88,36 +101,35 @@ export const RelatorioPecasProcessoPDF: React.FC<RelatorioPecasProcessoPDFProps>
|
||||
autoTable(doc, {
|
||||
head: headers,
|
||||
body: tableData,
|
||||
startY: yPos + 15,
|
||||
startY: yPos,
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 2,
|
||||
lineColor: [204, 204, 204],
|
||||
lineWidth: 0.5,
|
||||
fontSize: 8,
|
||||
cellPadding: 1.5,
|
||||
lineColor: [210, 210, 210],
|
||||
lineWidth: 0.1,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [233, 236, 239],
|
||||
textColor: 0,
|
||||
fillColor: [240, 240, 240],
|
||||
textColor: [40, 40, 40],
|
||||
fontStyle: 'bold',
|
||||
lineColor: [204, 204, 204],
|
||||
lineWidth: 0.5,
|
||||
lineWidth: 0.1,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [248, 249, 250],
|
||||
fillColor: [250, 250, 250],
|
||||
},
|
||||
columnStyles: {
|
||||
0: { cellWidth: 25, halign: 'center' }, // OF
|
||||
1: { cellWidth: 20, halign: 'center' }, // Fase
|
||||
2: { cellWidth: 25, halign: 'center' }, // Marca
|
||||
3: { cellWidth: 15, halign: 'center' }, // Qtd
|
||||
4: { cellWidth: 25, halign: 'right' }, // Peso Unit
|
||||
5: { cellWidth: 25, halign: 'right' }, // Peso Total
|
||||
6: { cellWidth: 20, halign: 'center' }, // Corte
|
||||
7: { cellWidth: 20, halign: 'center' }, // Solda
|
||||
8: { cellWidth: 25, halign: 'center' }, // Pint/Galv
|
||||
9: { cellWidth: 25, halign: 'center' }, // Expedição
|
||||
0: { cellWidth: 18, halign: 'center' }, // OF
|
||||
1: { cellWidth: 12, halign: 'center' }, // Fase
|
||||
2: { cellWidth: 20, halign: 'center' }, // Marca
|
||||
3: { cellWidth: 10, halign: 'center' }, // Qtd
|
||||
4: { cellWidth: 22, halign: 'right' }, // Peso Unit
|
||||
5: { cellWidth: 22, halign: 'right' }, // Peso Total
|
||||
6: { cellWidth: 16, halign: 'center' }, // Corte
|
||||
7: { cellWidth: 16, halign: 'center' }, // Solda
|
||||
8: { cellWidth: 22, halign: 'center' }, // Pint/Galv
|
||||
9: { cellWidth: 22, halign: 'center' }, // Expedição
|
||||
},
|
||||
margin: { left: 20, right: 20 },
|
||||
margin: { left: 14, right: 14, top: 20, bottom: 20 },
|
||||
tableWidth: 'auto',
|
||||
});
|
||||
|
||||
|
||||
@@ -41,15 +41,18 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
|
||||
printTemplate.innerHTML = `
|
||||
<style>
|
||||
@media print {
|
||||
@page { size: A4 portrait; margin: 1cm; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: white;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.a4-page {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 1.5cm;
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
table {
|
||||
@@ -58,18 +61,19 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
|
||||
page-break-inside: auto;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px 8px;
|
||||
font-size: 9pt;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 3px 6px;
|
||||
font-size: 8pt;
|
||||
text-align: center;
|
||||
}
|
||||
th {
|
||||
background-color: #e9ecef;
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
.header-info, .summary-info { font-size: 10pt; }
|
||||
.header-info, .summary-info { font-size: 9pt; }
|
||||
tr { page-break-inside: avoid; }
|
||||
thead { display: table-header-group; }
|
||||
}
|
||||
@@ -81,7 +85,7 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
|
||||
.a4-page {
|
||||
width: 21cm;
|
||||
min-height: 29.7cm;
|
||||
padding: 1.5cm;
|
||||
padding: 1cm;
|
||||
margin: 1cm auto;
|
||||
background: white;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
@@ -91,44 +95,43 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 4px 8px;
|
||||
font-size: 9pt;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 3px 6px;
|
||||
font-size: 8pt;
|
||||
text-align: center;
|
||||
}
|
||||
th {
|
||||
background-color: #e9ecef;
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
.header-info, .summary-info { font-size: 10pt; }
|
||||
.header-info, .summary-info { font-size: 9pt; }
|
||||
</style>
|
||||
|
||||
<div class="a4-page">
|
||||
<!-- Cabeçalho -->
|
||||
<header style="margin-bottom: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid #ccc; padding-bottom: 8px;">
|
||||
<header style="margin-bottom: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 1px solid #ccc; padding-bottom: 4px;">
|
||||
<div>
|
||||
<h1 style="font-size: 20px; font-weight: bold; color: #1f2937; margin: 0 0 4px 0;">Relatório de Status</h1>
|
||||
<p style="font-size: 14px; color: #6b7280; margin: 0;" class="header-info">Ordem de Fabricação: <span style="font-weight: 600;">${selectedOF}${faseText}</span></p>
|
||||
<h1 style="font-size: 18px; font-weight: bold; color: #1f2937; margin: 0 0 2px 0;">Relatório de Status de Produção</h1>
|
||||
<p style="font-size: 12px; color: #6b7280; margin: 0;" class="header-info">OF: <span style="font-weight: 600;">${selectedOF}${faseText}</span></p>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<p style="font-size: 14px; color: #6b7280; margin: 0;" class="header-info">Gerado em:</p>
|
||||
<p style="font-size: 14px; color: #9ca3af; margin: 0;" class="header-info">${currentDate}</p>
|
||||
<p style="font-size: 11px; color: #9ca3af; margin: 0;" class="header-info">Gerado em: ${currentDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Resumo -->
|
||||
<section style="margin-bottom: 16px;">
|
||||
<h2 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #374151;">Resumo por Processo</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; font-size: 12px; color: #6b7280; padding: 8px; border: 1px solid #e5e7eb; border-radius: 8px; background-color: #f9fafb;" class="summary-info">
|
||||
<div><strong>Total de Peças:</strong> <span style="font-family: monospace;">${estatisticas.totalPecas}</span></div>
|
||||
<div><strong>Peso Corte:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalCorte.toFixed(2)} kg</span></div>
|
||||
<div><strong>Peso Solda:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalSolda.toFixed(2)} kg</span></div>
|
||||
<div><strong>Peso Pintura:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalPintura.toFixed(2)} kg</span></div>
|
||||
<div><strong>Peso Expedição:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalExpedicao.toFixed(2)} kg</span></div>
|
||||
<section style="margin-bottom: 12px;">
|
||||
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; font-size: 11px; color: #4b5563; padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 6px; background-color: #f9fafb;" class="summary-info">
|
||||
<div><strong>Total Peças:</strong> ${estatisticas.totalPecas}</div>
|
||||
<div><strong>Corte:</strong> ${estatisticas.pesoTotalCorte.toFixed(0)} kg</div>
|
||||
<div><strong>Solda:</strong> ${estatisticas.pesoTotalSolda.toFixed(0)} kg</div>
|
||||
<div><strong>Pintura:</strong> ${estatisticas.pesoTotalPintura.toFixed(0)} kg</div>
|
||||
<div><strong>Expedição:</strong> ${estatisticas.pesoTotalExpedicao.toFixed(0)} kg</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
Settings,
|
||||
Shield,
|
||||
UserCog,
|
||||
Cog,
|
||||
Globe
|
||||
Cog
|
||||
} from "lucide-react";
|
||||
import { MenuItem, MenuGroup } from './types';
|
||||
|
||||
@@ -40,9 +39,9 @@ export const menuGroups: MenuGroup[] = [
|
||||
icon: FileText,
|
||||
subItems: [
|
||||
{
|
||||
key: "ficha-tecnica-of",
|
||||
title: "Ficha Técnica da OF",
|
||||
url: "/cadastro-of",
|
||||
key: "ordens-fabricacao",
|
||||
title: "Ordens de Fabricação",
|
||||
url: "/ofs",
|
||||
},
|
||||
{
|
||||
key: "cadastro-pecas",
|
||||
@@ -71,12 +70,6 @@ export const menuGroups: MenuGroup[] = [
|
||||
key: "inconsistencias",
|
||||
title: "Ver Inconsistências",
|
||||
url: "/ferramentas/inconsistencias",
|
||||
},
|
||||
{
|
||||
key: "webclone",
|
||||
title: "WebClone AI",
|
||||
url: "/ferramentas/webclone",
|
||||
icon: Globe,
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -103,9 +96,9 @@ export const menuGroups: MenuGroup[] = [
|
||||
icon: FolderOpen,
|
||||
subItems: [
|
||||
{
|
||||
key: "ordens-fabricacao",
|
||||
title: "Ordens de Fabricação",
|
||||
url: "/ofs",
|
||||
key: "ficha-tecnica-of",
|
||||
title: "Ficha Técnica da OF",
|
||||
url: "/cadastro-of",
|
||||
},
|
||||
{
|
||||
key: "cronograma",
|
||||
|
||||
@@ -20,7 +20,8 @@ import { UserProfile, UserFunction, UserPrivilege } from '@/hooks/useUserManagem
|
||||
import { AvatarUpload } from '@/components/ui/avatar-upload';
|
||||
import { useProfileImage } from '@/hooks/useProfileImage';
|
||||
import { usePasswordManagement } from '@/hooks/usePasswordManagement';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { useUserManagement } from '@/hooks/useUserManagement';
|
||||
import { Eye, EyeOff, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface UserModalProps {
|
||||
user: UserProfile | null;
|
||||
@@ -35,7 +36,9 @@ interface UserModalProps {
|
||||
export function UserModal({ user, functions, privileges, onSave, onCreate, onClose, readOnly = false }: UserModalProps) {
|
||||
const { updateProfileImage, removeProfileImage, updating } = useProfileImage();
|
||||
const { changeUserPassword, isChangingPassword } = usePasswordManagement();
|
||||
const { deleteUserAndLogtoAccount } = useUserManagement();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isDeletingLogto, setIsDeletingLogto] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
full_name: '',
|
||||
@@ -126,6 +129,18 @@ export function UserModal({ user, functions, privileges, onSave, onCreate, onClo
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeleteLogtoAndSupabase = async () => {
|
||||
if (!user || isReadOnly) return;
|
||||
if (confirm(`ATENÇÃO! Esta ação irá apagar a conta do usuário ${user.email} permanentemente no LOGTO e no Supabase. O usuário perderá o acesso totalmente. Tem certeza?`)) {
|
||||
setIsDeletingLogto(true);
|
||||
const success = await deleteUserAndLogtoAccount(user.id, user.email);
|
||||
setIsDeletingLogto(false);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-md">
|
||||
@@ -297,11 +312,23 @@ export function UserModal({ user, functions, privileges, onSave, onCreate, onClo
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={updating || (isCreateMode && !formData.email)}
|
||||
disabled={updating || (isCreateMode && !formData.email) || isDeletingLogto}
|
||||
>
|
||||
{isCreateMode ? 'Criar Usuário' : 'Salvar'}
|
||||
</Button>
|
||||
)}
|
||||
{!isCreateMode && !isReadOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteLogtoAndSupabase}
|
||||
disabled={isDeletingLogto}
|
||||
className="flex items-center gap-1 bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{isDeletingLogto ? 'Excluindo...' : 'Excluir Conta Logto'}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{isReadOnly ? 'Fechar' : 'Cancelar'}
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,7 @@ export type RouteGuard = 'public' | 'protected' | 'admin' | 'resource';
|
||||
|
||||
export interface RouteDefinition {
|
||||
path: string;
|
||||
component: () => Promise<{ default: ComponentType<any> }>;
|
||||
component: () => Promise<{ default: ComponentType<unknown> }>;
|
||||
guard: RouteGuard;
|
||||
resourceKey?: string;
|
||||
}
|
||||
@@ -24,6 +24,11 @@ export const routeConfig: RouteDefinition[] = [
|
||||
component: () => import('@/pages/Auth'),
|
||||
guard: 'public',
|
||||
},
|
||||
{
|
||||
path: '/callback',
|
||||
component: () => import('@/pages/Callback'),
|
||||
guard: 'public',
|
||||
},
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────
|
||||
{
|
||||
@@ -78,12 +83,7 @@ export const routeConfig: RouteDefinition[] = [
|
||||
guard: 'resource',
|
||||
resourceKey: 'ferramentas-inconsistencias',
|
||||
},
|
||||
{
|
||||
path: '/ferramentas/webclone',
|
||||
component: () => import('@/pages/WebClone'),
|
||||
guard: 'resource',
|
||||
resourceKey: 'ferramentas-webclone',
|
||||
},
|
||||
|
||||
|
||||
// ── Estoque ──────────────────────────────────────────────
|
||||
{
|
||||
@@ -229,13 +229,13 @@ export const routeConfig: RouteDefinition[] = [
|
||||
{
|
||||
path: '/configuracoes',
|
||||
component: () => import('@/pages/Configuracoes'),
|
||||
guard: 'resource',
|
||||
guard: 'admin',
|
||||
resourceKey: 'configuracoes-gerais',
|
||||
},
|
||||
{
|
||||
path: '/admin/theme-customization',
|
||||
component: () => import('@/pages/ThemeCustomizationPage'),
|
||||
guard: 'resource',
|
||||
guard: 'admin',
|
||||
resourceKey: 'theme-customization',
|
||||
},
|
||||
|
||||
|
||||
@@ -86,9 +86,9 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
|
||||
data_apontamento,
|
||||
quantidade_produzida,
|
||||
tipo_apontamento,
|
||||
processo:processos_fabricacao(nome, ordem),
|
||||
peca:pecas(peso_unitario),
|
||||
componente:componentes_peca(peso_unitario)
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
|
||||
componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario)
|
||||
`)
|
||||
.gte('data_apontamento', dataInicio)
|
||||
.lte('data_apontamento', dataFim)
|
||||
@@ -216,6 +216,7 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [periodo]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -84,16 +84,8 @@ export const useApontamentosProducao = () => {
|
||||
console.log('🔍 Iniciando busca COMPLETA de apontamentos...');
|
||||
|
||||
// Verificar se o usuário está autenticado
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError) {
|
||||
console.error('❌ Erro de autenticação:', authError);
|
||||
toast.error('Erro de autenticação: ' + authError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.warn('⚠️ Usuário não autenticado');
|
||||
toast.error('Usuário não autenticado');
|
||||
console.warn('⚠️ Usuário não autenticado em useApontamentosProducao');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,7 +113,7 @@ export const useApontamentosProducao = () => {
|
||||
console.log(`📊 TOTAL DE REGISTROS NA TABELA: ${totalCount}`);
|
||||
|
||||
// SEGUNDA VERIFICAÇÃO: Buscar TODOS os apontamentos sem qualquer limitação
|
||||
let allApontamentos: any[] = [];
|
||||
let allApontamentos: ApontamentoProducao[] = [];
|
||||
let pageNumber: number = 0;
|
||||
const itemsPerPage: number = 1000; // Buscar em lotes de 1000 para evitar timeout
|
||||
let hasMoreData: boolean = true;
|
||||
@@ -137,9 +129,9 @@ export const useApontamentosProducao = () => {
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
*,
|
||||
peca:pecas(marca, descricao, peso_unitario, etapa_fase),
|
||||
processo:processos_fabricacao(nome, ordem),
|
||||
componente:componentes_peca(marca_componente, descricao, peso_unitario)
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(marca, descricao, peso_unitario, etapa_fase),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
|
||||
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente, descricao, peso_unitario)
|
||||
`)
|
||||
.range(startIndex, endIndex)
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -156,7 +148,7 @@ export const useApontamentosProducao = () => {
|
||||
break;
|
||||
}
|
||||
|
||||
allApontamentos = [...allApontamentos, ...pageData];
|
||||
allApontamentos = [...allApontamentos, ...(pageData as unknown as ApontamentoProducao[])];
|
||||
console.log(`✅ Página ${pageNumber + 1}: ${pageData.length} registros carregados. Total acumulado: ${allApontamentos.length}`);
|
||||
|
||||
// Se retornou menos que o itemsPerPage, chegamos ao fim
|
||||
@@ -220,7 +212,7 @@ export const useApontamentosProducao = () => {
|
||||
toast.error('Erro desconhecido ao carregar apontamentos');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [user]);
|
||||
|
||||
const fetchProcessos = useCallback(async () => {
|
||||
try {
|
||||
@@ -325,7 +317,19 @@ export const useApontamentosProducao = () => {
|
||||
|
||||
console.log('🔄 Criando apontamento:', apontamento);
|
||||
|
||||
const insertData: any = {
|
||||
interface InsertApontamentoData {
|
||||
of_number: string;
|
||||
tipo_apontamento: 'peca' | 'componente';
|
||||
processo_id: string;
|
||||
quantidade_produzida: number;
|
||||
data_apontamento: string;
|
||||
observacoes?: string | null;
|
||||
created_by?: string;
|
||||
peca_id?: string | null;
|
||||
componente_id?: string | null;
|
||||
}
|
||||
|
||||
const insertData: InsertApontamentoData = {
|
||||
of_number: apontamento.of_number,
|
||||
tipo_apontamento: apontamento.tipo_apontamento,
|
||||
processo_id: apontamento.processo_id,
|
||||
@@ -377,9 +381,13 @@ export const useApontamentosProducao = () => {
|
||||
toast.success('Apontamento registrado com sucesso!');
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ Erro ao criar apontamento:', error);
|
||||
toast.error('Erro ao registrar apontamento: ' + (error as any).message);
|
||||
const isConflict = error?.status === 409 || error?.code === '23505' || String(error?.message || '').includes('409');
|
||||
const errorMessage = isConflict
|
||||
? 'Este item ou processo já teve o apontamento registrado por outro usuário ou os dados em cache estão desatualizados. Por favor, clique em "Limpar" no cache e atualize a página.'
|
||||
: (error instanceof Error ? error.message : String(error));
|
||||
toast.error('Erro ao registrar apontamento: ' + errorMessage);
|
||||
return { success: false, error };
|
||||
}
|
||||
}, [user, fetchApontamentos]);
|
||||
|
||||
@@ -203,9 +203,9 @@ export const useApontamentosProducaoOtimizado = () => {
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
*,
|
||||
peca:pecas(marca, descricao),
|
||||
processo:processos_fabricacao(nome, ordem),
|
||||
componente:componentes_peca(marca_componente, descricao)
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(marca, descricao),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
|
||||
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente, descricao)
|
||||
`)
|
||||
.eq('of_number', ofNumber)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
@@ -3,15 +3,15 @@ import { useState, useCallback } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { naturalSort } from '@/utils/naturalSort';
|
||||
|
||||
interface InconsistenciaItem {
|
||||
export interface InconsistenciaItem {
|
||||
marca: string;
|
||||
tipo: string;
|
||||
descricao: string;
|
||||
detalhes: any;
|
||||
detalhes: Record<string, unknown>;
|
||||
acaoSugerida?: string;
|
||||
}
|
||||
|
||||
interface ResultadoAuditoria {
|
||||
export interface ResultadoAuditoria {
|
||||
inconsistencias: {
|
||||
processos: InconsistenciaItem[];
|
||||
quantidades: InconsistenciaItem[];
|
||||
@@ -50,9 +50,9 @@ export const useAuditoriaInconsistencias = () => {
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
*,
|
||||
processo:processos_fabricacao(nome, ordem),
|
||||
peca:pecas(marca, etapa_fase, quantidade),
|
||||
componente:componentes_peca(marca_componente)
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase, quantidade),
|
||||
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente)
|
||||
`)
|
||||
.eq('of_number', ofNumber)
|
||||
.then(({ data }) => data || []),
|
||||
@@ -253,8 +253,9 @@ export const useAuditoriaInconsistencias = () => {
|
||||
});
|
||||
|
||||
// Aplicar ordenação natural
|
||||
Object.keys(inconsistencias).forEach(categoria => {
|
||||
(inconsistencias as any)[categoria].sort((a: any, b: any) => naturalSort(a.marca, b.marca));
|
||||
Object.keys(inconsistencias).forEach(key => {
|
||||
const categoria = key as keyof typeof inconsistencias;
|
||||
inconsistencias[categoria].sort((a: InconsistenciaItem, b: InconsistenciaItem) => naturalSort(a.marca, b.marca));
|
||||
});
|
||||
|
||||
const resultado: ResultadoAuditoria = {
|
||||
|
||||
+152
-368
@@ -1,401 +1,185 @@
|
||||
import { useState, useEffect, createContext, useContext, ReactNode } from 'react';
|
||||
import { User, Session } from '@supabase/supabase-js';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { logger } from '@/utils/logger';
|
||||
// Hook de autenticação usando Logto (sem Supabase Auth)
|
||||
// Mantém a MESMA shape do useAuth original pra não quebrar consumers
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
loading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<{ error: any }>;
|
||||
signUp: (email: string, password: string) => Promise<{ error: any }>;
|
||||
signOut: () => Promise<void>;
|
||||
updatePassword: (password: string) => Promise<{ error: any }>;
|
||||
isRecoveryFlow: boolean;
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import {
|
||||
LogtoUser,
|
||||
signIn as logtoSignIn,
|
||||
signOut as logtoSignOut,
|
||||
getUser as logtoGetUser,
|
||||
handleCallback,
|
||||
isAuthenticated,
|
||||
requestPasswordReset,
|
||||
} from '@/lib/logto/client';
|
||||
|
||||
// Sincroniza user Logto com profiles Supabase e retorna o UUID real do Supabase
|
||||
async function syncUserToProfile(user: LogtoUser): Promise<string> {
|
||||
if (!user?.sub) return user.sub;
|
||||
const email = user.email || `${user.username || 'user'}@logto.local`;
|
||||
|
||||
try {
|
||||
// 1. Tenta buscar o perfil existente pelo email
|
||||
const { data: existing } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('email', email)
|
||||
.maybeSingle();
|
||||
|
||||
let profileId = existing?.id;
|
||||
|
||||
// 2. Se existe, atualizamos o nome e retornamos o UUID
|
||||
if (profileId) {
|
||||
const name = user.name || user.username;
|
||||
if (name) {
|
||||
await supabase.from('profiles').update({
|
||||
full_name: name,
|
||||
}).eq('id', profileId);
|
||||
}
|
||||
return profileId;
|
||||
}
|
||||
|
||||
// 3. Se não existir, criamos um novo com um UUID gerado
|
||||
profileId = crypto.randomUUID();
|
||||
const { error: insertError } = await supabase.from('profiles').insert({
|
||||
id: profileId,
|
||||
email: email,
|
||||
full_name: user.name || user.username || 'Usuário Logto',
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
console.warn('Erro ao inserir novo profile no Supabase:', insertError);
|
||||
return user.sub;
|
||||
}
|
||||
|
||||
return profileId;
|
||||
} catch (err) {
|
||||
console.warn('Erro ao sincronizar profile Supabase:', err);
|
||||
return user.sub;
|
||||
}
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
export interface UseAuthReturn {
|
||||
user: LogtoUser | null;
|
||||
loading: boolean;
|
||||
authInitialized: boolean;
|
||||
isRecoveryFlow: boolean;
|
||||
signIn: (email?: string, password?: string) => Promise<{ error: unknown }>;
|
||||
signUp: (email: string, password: string) => Promise<{ error: unknown }>;
|
||||
signOut: () => Promise<void>;
|
||||
updatePassword: (password: string) => Promise<{ error: unknown }>;
|
||||
handleCallback: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<UseAuthReturn | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [user, setUser] = useState<LogtoUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
|
||||
const [authInitialized, setAuthInitialized] = useState(false);
|
||||
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
|
||||
const callbackHandled = useRef(false);
|
||||
|
||||
// Verifica se o usuário é admin ou desenvolvedor (excluídos dos logs de sessão)
|
||||
const isAdminOrDeveloper = async (userId: string): Promise<boolean> => {
|
||||
try {
|
||||
const { data: adminRoles, error: adminError } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', userId)
|
||||
.eq('role', 'admin')
|
||||
.maybeSingle();
|
||||
// Detecta callback URL e processa
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has('code')) {
|
||||
if (callbackHandled.current) return;
|
||||
callbackHandled.current = true;
|
||||
|
||||
if (!adminError && adminRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('function_id, functions(name)')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (profile?.functions?.name === 'Desenvolvedor') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Processa tokens de recuperação de senha na URL
|
||||
const processRecoveryTokens = () => {
|
||||
try {
|
||||
const currentUrl = window.location.href;
|
||||
const urlObj = new URL(currentUrl);
|
||||
|
||||
const recoveryType = urlObj.searchParams.get('type');
|
||||
const hashParams = new URLSearchParams(urlObj.hash.substring(1));
|
||||
const accessToken = hashParams.get('access_token');
|
||||
const refreshToken = hashParams.get('refresh_token');
|
||||
const tokenType = hashParams.get('type');
|
||||
|
||||
const isRecovery = recoveryType === 'recovery' || tokenType === 'recovery' || (accessToken && refreshToken);
|
||||
|
||||
if (isRecovery) {
|
||||
setIsRecoveryFlow(true);
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken
|
||||
}).then(({ data, error }) => {
|
||||
if (!error) {
|
||||
setSession(data.session);
|
||||
setUser(data.session?.user || null);
|
||||
setIsRecoveryFlow(true);
|
||||
setLoading(true);
|
||||
handleCallback().then((ok) => {
|
||||
if (ok) {
|
||||
logtoGetUser().then(async (u) => {
|
||||
if (u) {
|
||||
const profileId = await syncUserToProfile(u);
|
||||
u.id = profileId;
|
||||
}
|
||||
const cleanUrl = `${window.location.origin}/auth?type=recovery`;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
setUser(u);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Marca usuário como online no sistema
|
||||
const setUserOnline = async (userId: string) => {
|
||||
if (!authInitialized) return;
|
||||
|
||||
try {
|
||||
const shouldSkipLogging = await isAdminOrDeveloper(userId);
|
||||
if (shouldSkipLogging) return;
|
||||
|
||||
try {
|
||||
await supabase.rpc('set_user_online', { user_id_param: userId });
|
||||
} catch {
|
||||
// RPC não encontrada — continua sem erro
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erro ao marcar usuário como online:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const setUserOffline = async (userId: string) => {
|
||||
if (!authInitialized) return;
|
||||
|
||||
try {
|
||||
const shouldSkipLogging = await isAdminOrDeveloper(userId);
|
||||
if (shouldSkipLogging) return;
|
||||
|
||||
try {
|
||||
await supabase.rpc('set_user_offline', { user_id_param: userId });
|
||||
} catch {
|
||||
// RPC não encontrada — continua sem erro
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erro ao marcar usuário como offline:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const startSessionLog = async (userId: string) => {
|
||||
if (!authInitialized) return;
|
||||
|
||||
try {
|
||||
const shouldSkipLogging = await isAdminOrDeveloper(userId);
|
||||
if (shouldSkipLogging) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('user_session_logs')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
user_agent: navigator.userAgent,
|
||||
is_active: true
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error('Erro ao registrar início de sessão:', error);
|
||||
} else if (data) {
|
||||
localStorage.setItem('currentSessionId', data.id);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erro inesperado ao registrar início de sessão:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const endSessionLog = async () => {
|
||||
if (!authInitialized) return;
|
||||
|
||||
try {
|
||||
const sessionId = localStorage.getItem('currentSessionId');
|
||||
if (sessionId) {
|
||||
try {
|
||||
await supabase.rpc('end_user_session', { session_id: sessionId });
|
||||
} catch {
|
||||
// RPC não encontrada — continua sem erro
|
||||
}
|
||||
localStorage.removeItem('currentSessionId');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erro ao finalizar sessão:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
const hasRecoveryTokens = processRecoveryTokens();
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
(event, session) => {
|
||||
if (!mounted) return;
|
||||
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
|
||||
if (event === 'SIGNED_IN' && session?.user) {
|
||||
setTimeout(() => {
|
||||
if (mounted && authInitialized) {
|
||||
setUserOnline(session.user.id);
|
||||
startSessionLog(session.user.id);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
setIsRecoveryFlow(false);
|
||||
|
||||
const currentUser = user;
|
||||
if (currentUser && mounted && authInitialized) {
|
||||
setTimeout(() => {
|
||||
setUserOffline(currentUser.id);
|
||||
endSessionLog();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!hasRecoveryTokens) {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (mounted && session?.user) {
|
||||
setSession(session);
|
||||
setUser(session.user);
|
||||
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
setAuthInitialized(true);
|
||||
setUserOnline(session.user.id);
|
||||
const existingSessionId = localStorage.getItem('currentSessionId');
|
||||
if (!existingSessionId) {
|
||||
startSessionLog(session.user.id);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
} else if (mounted) {
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
setAuthInitialized(true);
|
||||
}
|
||||
} catch (sessionError) {
|
||||
logger.error('Erro ao verificar sessão:', sessionError);
|
||||
if (mounted) {
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
setAuthInitialized(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setAuthInitialized(true);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Erro na inicialização da autenticação:', error);
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
setAuthInitialized(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = initializeAuth();
|
||||
|
||||
return () => {
|
||||
cleanup.then((cleanupFn) => {
|
||||
if (cleanupFn) cleanupFn();
|
||||
setAuthInitialized(true);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setAuthInitialized(true);
|
||||
if (isAuthenticated()) {
|
||||
logtoGetUser().then(async (u) => {
|
||||
if (u) {
|
||||
const profileId = await syncUserToProfile(u);
|
||||
u.id = profileId;
|
||||
}
|
||||
setUser(u);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signIn = useCallback(async (_email?: string, _password?: string) => {
|
||||
try {
|
||||
await logtoSignIn();
|
||||
return { error: null };
|
||||
} catch (err) {
|
||||
return { error: err };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signUp = useCallback(async (_email: string, _password: string) => {
|
||||
// Logto tem tela própria de signup (botão na página de login)
|
||||
await logtoSignIn();
|
||||
return { error: null };
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
localStorage.removeItem('userLoginTime');
|
||||
setIsRecoveryFlow(false);
|
||||
await logtoSignOut();
|
||||
}, []);
|
||||
|
||||
const updatePassword = useCallback(async (_password: string) => {
|
||||
return {
|
||||
error: new Error(
|
||||
'Para alterar senha, acesse as configurações da conta no Logto (gerenciado pelo IdP)'
|
||||
),
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Limpeza periódica de usuários offline e listener de fechamento de janela
|
||||
useEffect(() => {
|
||||
let cleanupInterval: NodeJS.Timeout;
|
||||
const handleCallbackFn = useCallback(async (): Promise<boolean> => {
|
||||
return await handleCallback();
|
||||
}, []);
|
||||
|
||||
if (user && authInitialized) {
|
||||
cleanupInterval = setInterval(async () => {
|
||||
try {
|
||||
await supabase.rpc('cleanup_offline_users');
|
||||
} catch {
|
||||
// Ignora se RPC não existir
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
if (user) {
|
||||
const sessionId = localStorage.getItem('currentSessionId');
|
||||
if (sessionId) {
|
||||
try {
|
||||
supabase.rpc('end_user_session', { session_id: sessionId });
|
||||
} catch {
|
||||
// Ignora no unload
|
||||
}
|
||||
localStorage.removeItem('currentSessionId');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
}
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}
|
||||
}, [user?.id, authInitialized]);
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
return { error };
|
||||
} catch (error) {
|
||||
logger.error('Erro crítico no login:', error);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = async (email: string, password: string) => {
|
||||
try {
|
||||
const redirectUrl = `${window.location.origin}/`;
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: redirectUrl
|
||||
}
|
||||
});
|
||||
return { error };
|
||||
} catch (error) {
|
||||
logger.error('Erro crítico no signup:', error);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
const currentUser = user;
|
||||
if (currentUser && authInitialized) {
|
||||
setTimeout(() => {
|
||||
setUserOffline(currentUser.id);
|
||||
endSessionLog();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
setIsRecoveryFlow(false);
|
||||
try {
|
||||
await supabase.auth.signOut();
|
||||
} catch (error) {
|
||||
logger.error('Erro no signOut:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePassword = async (password: string) => {
|
||||
try {
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: password
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
setIsRecoveryFlow(false);
|
||||
}
|
||||
|
||||
return { error };
|
||||
} catch (error) {
|
||||
logger.error('Erro crítico ao atualizar senha:', error);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
const value: UseAuthReturn = {
|
||||
user,
|
||||
session,
|
||||
loading,
|
||||
authInitialized,
|
||||
isRecoveryFlow,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
updatePassword,
|
||||
isRecoveryFlow,
|
||||
handleCallback: handleCallbackFn,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
export function useAuth(): UseAuthReturn {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { requestPasswordReset };
|
||||
@@ -0,0 +1,428 @@
|
||||
import { useState, useEffect, createContext, useContext, ReactNode } from 'react';
|
||||
import { User, Session } from '@supabase/supabase-js';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
loading: boolean;
|
||||
signIn: () => Promise<{ error: unknown }>;
|
||||
signUp: (email: string, password: string) => Promise<{ error: unknown }>;
|
||||
signOut: () => Promise<void>;
|
||||
updatePassword: (password: string) => Promise<{ error: unknown }>;
|
||||
isRecoveryFlow: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
|
||||
const [authInitialized, setAuthInitialized] = useState(false);
|
||||
|
||||
// Verifica se o usuário é admin ou desenvolvedor (excluídos dos logs de sessão)
|
||||
const isAdminOrDeveloper = async (userId: string): Promise<boolean> => {
|
||||
try {
|
||||
const { data: adminRoles, error: adminError } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', userId)
|
||||
.eq('role', 'admin')
|
||||
.maybeSingle();
|
||||
|
||||
if (!adminError && adminRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('function_id, functions(name)')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (profile?.functions?.name === 'Desenvolvedor') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Processa tokens de recuperação de senha na URL
|
||||
const processRecoveryTokens = () => {
|
||||
try {
|
||||
const currentUrl = window.location.href;
|
||||
const urlObj = new URL(currentUrl);
|
||||
|
||||
const recoveryType = urlObj.searchParams.get('type');
|
||||
const hashParams = new URLSearchParams(urlObj.hash.substring(1));
|
||||
const accessToken = hashParams.get('access_token');
|
||||
const refreshToken = hashParams.get('refresh_token');
|
||||
const tokenType = hashParams.get('type');
|
||||
|
||||
const isRecovery = recoveryType === 'recovery' || tokenType === 'recovery' || (accessToken && refreshToken);
|
||||
|
||||
if (isRecovery) {
|
||||
setIsRecoveryFlow(true);
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken
|
||||
}).then(({ data, error }) => {
|
||||
if (!error) {
|
||||
setSession(data.session);
|
||||
setUser(data.session?.user || null);
|
||||
setIsRecoveryFlow(true);
|
||||
}
|
||||
const cleanUrl = `${window.location.origin}/auth?type=recovery`;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Marca usuário como online no sistema
|
||||
const setUserOnline = async (userId: string) => {
|
||||
if (!authInitialized) return;
|
||||
|
||||
try {
|
||||
const shouldSkipLogging = await isAdminOrDeveloper(userId);
|
||||
if (shouldSkipLogging) return;
|
||||
|
||||
try {
|
||||
await supabase.rpc('set_user_online', { user_id_param: userId });
|
||||
} catch {
|
||||
// RPC não encontrada — continua sem erro
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erro ao marcar usuário como online:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const setUserOffline = async (userId: string) => {
|
||||
if (!authInitialized) return;
|
||||
|
||||
try {
|
||||
const shouldSkipLogging = await isAdminOrDeveloper(userId);
|
||||
if (shouldSkipLogging) return;
|
||||
|
||||
try {
|
||||
await supabase.rpc('set_user_offline', { user_id_param: userId });
|
||||
} catch {
|
||||
// RPC não encontrada — continua sem erro
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erro ao marcar usuário como offline:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const startSessionLog = async (userId: string) => {
|
||||
if (!authInitialized) return;
|
||||
|
||||
try {
|
||||
const shouldSkipLogging = await isAdminOrDeveloper(userId);
|
||||
if (shouldSkipLogging) return;
|
||||
|
||||
// Desativar qualquer sessão antiga que ficou presa (fechar abas sem logout)
|
||||
await supabase
|
||||
.from('user_session_logs')
|
||||
.update({ is_active: false, session_end: new Date().toISOString() })
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('user_session_logs')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
user_agent: navigator.userAgent,
|
||||
is_active: true
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error('Erro ao registrar início de sessão:', error);
|
||||
} else if (data) {
|
||||
localStorage.setItem('currentSessionId', data.id);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erro inesperado ao registrar início de sessão:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const endSessionLog = async () => {
|
||||
if (!authInitialized) return;
|
||||
|
||||
try {
|
||||
const sessionId = localStorage.getItem('currentSessionId');
|
||||
if (sessionId) {
|
||||
try {
|
||||
await supabase.rpc('end_user_session', { session_id: sessionId });
|
||||
} catch {
|
||||
// RPC não encontrada — continua sem erro
|
||||
}
|
||||
localStorage.removeItem('currentSessionId');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erro ao finalizar sessão:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
const hasRecoveryTokens = processRecoveryTokens();
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
(event, session) => {
|
||||
if (!mounted) return;
|
||||
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
|
||||
if (event === 'SIGNED_IN' && session?.user) {
|
||||
setTimeout(() => {
|
||||
if (mounted && authInitialized) {
|
||||
setUserOnline(session.user.id);
|
||||
startSessionLog(session.user.id);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
setIsRecoveryFlow(false);
|
||||
|
||||
const currentUser = user;
|
||||
if (currentUser && mounted && authInitialized) {
|
||||
setTimeout(() => {
|
||||
setUserOffline(currentUser.id);
|
||||
endSessionLog();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!hasRecoveryTokens) {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (mounted && session?.user) {
|
||||
setSession(session);
|
||||
setUser(session.user);
|
||||
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
setAuthInitialized(true);
|
||||
setUserOnline(session.user.id);
|
||||
// Sempre cria uma nova sessão, resetando contadores anteriores
|
||||
startSessionLog(session.user.id);
|
||||
}
|
||||
}, 100);
|
||||
} else if (mounted) {
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
setAuthInitialized(true);
|
||||
}
|
||||
} catch (sessionError) {
|
||||
logger.error('Erro ao verificar sessão:', sessionError);
|
||||
if (mounted) {
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
setAuthInitialized(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setAuthInitialized(true);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Erro na inicialização da autenticação:', error);
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
setAuthInitialized(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = initializeAuth();
|
||||
|
||||
return () => {
|
||||
cleanup.then((cleanupFn) => {
|
||||
if (cleanupFn) cleanupFn();
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Limpeza periódica de usuários offline e listener de fechamento de janela
|
||||
useEffect(() => {
|
||||
let cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
if (user && authInitialized) {
|
||||
cleanupInterval = setInterval(async () => {
|
||||
// Enviar Heartbeat global da sessão (mantém o usuário online de fato)
|
||||
const sessionId = localStorage.getItem('currentSessionId');
|
||||
if (sessionId) {
|
||||
try {
|
||||
await supabase
|
||||
.from('user_session_logs')
|
||||
.update({ updated_at: new Date().toISOString() })
|
||||
.eq('id', sessionId);
|
||||
} catch (e) {
|
||||
console.error('Falha no heartbeat da sessão', e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await supabase.rpc('cleanup_offline_users');
|
||||
} catch {
|
||||
// Ignora se RPC não existir
|
||||
}
|
||||
}, 30000); // 30 segundos
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
if (user) {
|
||||
const sessionId = localStorage.getItem('currentSessionId');
|
||||
if (sessionId) {
|
||||
try {
|
||||
supabase.rpc('end_user_session', { session_id: sessionId });
|
||||
} catch {
|
||||
// Ignora no unload
|
||||
}
|
||||
localStorage.removeItem('currentSessionId');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
}
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, authInitialized]);
|
||||
|
||||
const signIn = async () => {
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'keycloak',
|
||||
options: {
|
||||
scopes: 'openid email profile',
|
||||
redirectTo: window.location.origin + '/'
|
||||
}
|
||||
});
|
||||
if (error) {
|
||||
return { error };
|
||||
}
|
||||
return { error: null };
|
||||
} catch (err) {
|
||||
return { error: err as Error };
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = async (email: string, password: string) => {
|
||||
try {
|
||||
const redirectUrl = `${window.location.origin}/`;
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: redirectUrl
|
||||
}
|
||||
});
|
||||
return { error };
|
||||
} catch (error) {
|
||||
logger.error('Erro crítico no signup:', error);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
const currentUser = user;
|
||||
if (currentUser && authInitialized) {
|
||||
setTimeout(() => {
|
||||
setUserOffline(currentUser.id);
|
||||
endSessionLog();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
setIsRecoveryFlow(false);
|
||||
localStorage.removeItem('userLoginTime'); // Reseta timer do front-end
|
||||
try {
|
||||
await supabase.auth.signOut();
|
||||
} catch (error) {
|
||||
logger.error('Erro no signOut:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePassword = async (password: string) => {
|
||||
try {
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: password
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
setIsRecoveryFlow(false);
|
||||
}
|
||||
|
||||
return { error };
|
||||
} catch (error) {
|
||||
logger.error('Erro crítico ao atualizar senha:', error);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
session,
|
||||
loading,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
updatePassword,
|
||||
isRecoveryFlow,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,249 +1 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from './useAuth';
|
||||
import { CronogramaOf } from '@/types/cronograma';
|
||||
|
||||
export const useCronogramaOperations = () => {
|
||||
const { user } = useAuth();
|
||||
const [cronogramas, setCronogramas] = useState<CronogramaOf[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadCronogramas = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const { data: cronogramasData, error: cronogramasError } = await supabase
|
||||
.from('cronogramas_of')
|
||||
.select(`
|
||||
*,
|
||||
ordens_fabricacao (
|
||||
num_of,
|
||||
descritivo
|
||||
),
|
||||
profiles!cronogramas_of_gestor_id_fkey (
|
||||
full_name,
|
||||
email
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (cronogramasError) throw cronogramasError;
|
||||
|
||||
// Carregar processos para cada cronograma
|
||||
const cronogramasComProcessos = await Promise.all(
|
||||
(cronogramasData || []).map(async (cronograma) => {
|
||||
const { data: processos, error: processosError } = await supabase
|
||||
.from('processos_cronograma')
|
||||
.select('*')
|
||||
.eq('cronograma_id', cronograma.id)
|
||||
.order('ordem');
|
||||
|
||||
if (processosError) throw processosError;
|
||||
|
||||
return {
|
||||
id: cronograma.id,
|
||||
of_id: cronograma.of_id,
|
||||
gestor_id: cronograma.gestor_id,
|
||||
revisao: cronograma.revisao,
|
||||
processos: processos || [],
|
||||
ordem_fabricacao: cronograma.ordens_fabricacao,
|
||||
gestor_profile: cronograma.profiles
|
||||
} as CronogramaOf;
|
||||
})
|
||||
);
|
||||
|
||||
setCronogramas(cronogramasComProcessos);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar cronogramas:', error);
|
||||
toast.error('Erro ao carregar cronogramas');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCronograma = async (cronograma: Partial<CronogramaOf> & { processos: any[] }) => {
|
||||
try {
|
||||
if (!user) throw new Error('Usuário não autenticado');
|
||||
|
||||
let cronogramaId: string;
|
||||
|
||||
if (cronograma.id) {
|
||||
// Atualizar cronograma existente
|
||||
const novaRevisao = cronograma.revisao ? cronograma.revisao + 1 : 1;
|
||||
|
||||
const { data: updatedCronograma, error: updateError } = await supabase
|
||||
.from('cronogramas_of')
|
||||
.update({
|
||||
gestor_id: cronograma.gestor_id,
|
||||
revisao: novaRevisao,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', cronograma.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) throw updateError;
|
||||
cronogramaId = updatedCronograma.id;
|
||||
|
||||
// Deletar processos antigos
|
||||
const { error: deleteError } = await supabase
|
||||
.from('processos_cronograma')
|
||||
.delete()
|
||||
.eq('cronograma_id', cronogramaId);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
} else {
|
||||
// Verificar se já existe cronograma para esta OF
|
||||
const { data: existingCronograma } = await supabase
|
||||
.from('cronogramas_of')
|
||||
.select('id, revisao')
|
||||
.eq('of_id', cronograma.of_id)
|
||||
.single();
|
||||
|
||||
if (existingCronograma) {
|
||||
// Atualizar cronograma existente
|
||||
const novaRevisao = existingCronograma.revisao + 1;
|
||||
|
||||
const { data: updatedCronograma, error: updateError } = await supabase
|
||||
.from('cronogramas_of')
|
||||
.update({
|
||||
gestor_id: cronograma.gestor_id,
|
||||
revisao: novaRevisao,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', existingCronograma.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) throw updateError;
|
||||
cronogramaId = updatedCronograma.id;
|
||||
|
||||
// Deletar processos antigos
|
||||
const { error: deleteError } = await supabase
|
||||
.from('processos_cronograma')
|
||||
.delete()
|
||||
.eq('cronograma_id', cronogramaId);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
} else {
|
||||
// Criar novo cronograma
|
||||
const { data: newCronograma, error: insertError } = await supabase
|
||||
.from('cronogramas_of')
|
||||
.insert({
|
||||
of_id: cronograma.of_id,
|
||||
gestor_id: cronograma.gestor_id,
|
||||
revisao: 1,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) throw insertError;
|
||||
cronogramaId = newCronograma.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Inserir novos processos
|
||||
const processosParaInserir = cronograma.processos.map(processo => ({
|
||||
cronograma_id: cronogramaId,
|
||||
nome_processo: processo.nome_processo,
|
||||
data_inicio: processo.data_inicio,
|
||||
data_fim: processo.data_fim,
|
||||
ordem: processo.ordem
|
||||
}));
|
||||
|
||||
const { error: processosError } = await supabase
|
||||
.from('processos_cronograma')
|
||||
.insert(processosParaInserir);
|
||||
|
||||
if (processosError) throw processosError;
|
||||
|
||||
toast.success('Cronograma salvo com sucesso!');
|
||||
loadCronogramas();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar cronograma:', error);
|
||||
toast.error('Erro ao salvar cronograma');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCronograma = async (cronogramaId: string) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('cronogramas_of')
|
||||
.delete()
|
||||
.eq('id', cronogramaId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Cronograma removido com sucesso!');
|
||||
loadCronogramas();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar cronograma:', error);
|
||||
toast.error('Erro ao remover cronograma');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getCronogramaPorOf = async (ofId: string) => {
|
||||
try {
|
||||
const { data: cronograma, error } = await supabase
|
||||
.from('cronogramas_of')
|
||||
.select(`
|
||||
*,
|
||||
ordens_fabricacao (
|
||||
num_of,
|
||||
descritivo
|
||||
),
|
||||
profiles!cronogramas_of_gestor_id_fkey (
|
||||
full_name,
|
||||
email
|
||||
)
|
||||
`)
|
||||
.eq('of_id', ofId)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') throw error;
|
||||
|
||||
if (!cronograma) return null;
|
||||
|
||||
const { data: processos, error: processosError } = await supabase
|
||||
.from('processos_cronograma')
|
||||
.select('*')
|
||||
.eq('cronograma_id', cronograma.id)
|
||||
.order('ordem');
|
||||
|
||||
if (processosError) throw processosError;
|
||||
|
||||
return {
|
||||
id: cronograma.id,
|
||||
of_id: cronograma.of_id,
|
||||
gestor_id: cronograma.gestor_id,
|
||||
revisao: cronograma.revisao,
|
||||
processos: processos || [],
|
||||
ordem_fabricacao: cronograma.ordens_fabricacao,
|
||||
gestor_profile: cronograma.profiles
|
||||
} as CronogramaOf;
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar cronograma:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCronogramas();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
cronogramas,
|
||||
loading,
|
||||
loadCronogramas,
|
||||
saveCronograma,
|
||||
deleteCronograma,
|
||||
getCronogramaPorOf
|
||||
};
|
||||
};
|
||||
export { useCronogramas as useCronogramaOperations } from './useCronogramas';
|
||||
|
||||
@@ -47,7 +47,7 @@ export const useCronogramas = () => {
|
||||
descritivo,
|
||||
peso_total
|
||||
),
|
||||
profiles!gestor_id (
|
||||
profiles!cronogramas_of_gestor_id_fkey (
|
||||
full_name,
|
||||
email
|
||||
)
|
||||
@@ -89,23 +89,17 @@ export const useCronogramas = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveCronograma = async (cronograma: Omit<CronogramaOf, 'id'>) => {
|
||||
const saveCronograma = async (cronograma: Partial<CronogramaOf> & { processos: ProcessoCronograma[] }) => {
|
||||
try {
|
||||
if (!user) throw new Error('Usuário não autenticado');
|
||||
|
||||
// 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 (!cronograma.of_id) throw new Error('Selecione uma Ordem de Fabricação');
|
||||
if (!cronograma.gestor_id) throw new Error('Selecione o gestor responsável');
|
||||
|
||||
let cronogramaId: string;
|
||||
let novaRevisao = 1;
|
||||
|
||||
if (existingCronograma) {
|
||||
// Atualizar cronograma existente
|
||||
novaRevisao = existingCronograma.revisao + 1;
|
||||
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')
|
||||
@@ -114,7 +108,7 @@ export const useCronogramas = () => {
|
||||
revisao: novaRevisao,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', existingCronograma.id)
|
||||
.eq('id', cronograma.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
@@ -129,43 +123,80 @@ export const useCronogramas = () => {
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
} else {
|
||||
// Criar novo cronograma
|
||||
const { data: newCronograma, error: insertError } = await supabase
|
||||
// Verificar se já existe cronograma para esta OF (usando maybeSingle para evitar erro PGRST116)
|
||||
const { data: existingCronograma } = await supabase
|
||||
.from('cronogramas_of')
|
||||
.insert({
|
||||
of_id: cronograma.of_id,
|
||||
gestor_id: cronograma.gestor_id,
|
||||
revisao: 1,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
.select('id, revisao')
|
||||
.eq('of_id', cronograma.of_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (insertError) throw insertError;
|
||||
cronogramaId = newCronograma.id;
|
||||
if (existingCronograma) {
|
||||
// Atualizar cronograma existente
|
||||
const novaRevisao = (existingCronograma.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', 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
|
||||
}));
|
||||
if (cronograma.processos && cronograma.processos.length > 0) {
|
||||
const processosParaInserir = cronograma.processos.map((processo, idx) => ({
|
||||
cronograma_id: cronogramaId,
|
||||
nome_processo: processo.nome_processo,
|
||||
data_inicio: processo.data_inicio,
|
||||
data_fim: processo.data_fim,
|
||||
ordem: processo.ordem || idx + 1
|
||||
}));
|
||||
|
||||
const { error: processosError } = await supabase
|
||||
.from('processos_cronograma')
|
||||
.insert(processosParaInserir);
|
||||
const { error: processosError } = await supabase
|
||||
.from('processos_cronograma')
|
||||
.insert(processosParaInserir);
|
||||
|
||||
if (processosError) throw processosError;
|
||||
if (processosError) throw processosError;
|
||||
}
|
||||
|
||||
toast.success('Cronograma salvo com sucesso!');
|
||||
loadCronogramas();
|
||||
await loadCronogramas();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar cronograma:', error);
|
||||
toast.error('Erro ao salvar cronograma');
|
||||
const errMsg = error instanceof Error ? error.message : 'Erro ao salvar cronograma';
|
||||
toast.error(errMsg);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -180,7 +211,7 @@ export const useCronogramas = () => {
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Cronograma removido com sucesso!');
|
||||
loadCronogramas();
|
||||
await loadCronogramas();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar cronograma:', error);
|
||||
@@ -200,16 +231,15 @@ export const useCronogramas = () => {
|
||||
descritivo,
|
||||
peso_total
|
||||
),
|
||||
profiles!gestor_id (
|
||||
profiles!cronogramas_of_gestor_id_fkey (
|
||||
full_name,
|
||||
email
|
||||
)
|
||||
`)
|
||||
.eq('of_id', ofId)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') throw error;
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
if (!cronograma) return null;
|
||||
|
||||
const { data: processos, error: processosError } = await supabase
|
||||
|
||||
@@ -64,8 +64,8 @@ export const useDashboardProducao = (ofNumber: string) => {
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
*,
|
||||
peca:pecas(peso_unitario),
|
||||
processo:processos_fabricacao(nome, ordem)
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem)
|
||||
`)
|
||||
.eq('of_number', ofNumber);
|
||||
|
||||
@@ -172,6 +172,7 @@ export const useDashboardProducao = (ofNumber: string) => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ofNumber]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -44,7 +44,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
const [dashboardData, setDashboardData] = useState<DashboardDataOtimizado | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
const fetchDashboardData = useCallback(async () => {
|
||||
if (!ofNumber) {
|
||||
setDashboardData(null);
|
||||
return;
|
||||
@@ -102,6 +102,26 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// CORREÇÃO AUTOMÁTICA: Evita que erros de digitação (ex: digitar 1.856 ao invés de 1856 kg na OF)
|
||||
// causem porcentagens irreais (ex: 100000%). Compara com a soma real das peças.
|
||||
if (pesoTotalPlanejado > 0) {
|
||||
const { data: pecasVerificar } = await supabase.from('pecas').select('peso_unitario, quantidade').eq('of_number', ofNumber);
|
||||
if (pecasVerificar && pecasVerificar.length > 0) {
|
||||
const pesoBrutoPecas = pecasVerificar.reduce((sum, p) => sum + ((p.peso_unitario || 0) * (p.quantidade || 0)), 0);
|
||||
|
||||
if (pesoBrutoPecas > pesoTotalPlanejado * 50) {
|
||||
console.log(`Corrigindo erro de digitação de peso da OF. Planejado original: ${pesoTotalPlanejado} kg, Soma das peças: ${pesoBrutoPecas} kg`);
|
||||
// Se for um erro típico de 1000x (usou ponto para separar milhar)
|
||||
if (pesoBrutoPecas <= pesoTotalPlanejado * 3000 && pesoBrutoPecas >= pesoTotalPlanejado * 500) {
|
||||
pesoTotalPlanejado = pesoTotalPlanejado * 1000;
|
||||
} else {
|
||||
// Se for outro erro grosseiro, assume que o peso correto é o das peças
|
||||
pesoTotalPlanejado = pesoBrutoPecas;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Peso total planejado (BASE DE TODOS OS CÁLCULOS):', pesoTotalPlanejado, 'kg');
|
||||
|
||||
// 4. Buscar apontamentos da OF com joins corretos
|
||||
@@ -109,9 +129,9 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
*,
|
||||
peca:pecas(peso_unitario, marca),
|
||||
processo:processos_fabricacao(nome, ordem, cor),
|
||||
componente:componentes_peca(peso_unitario, marca_componente)
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario, marca),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem, cor),
|
||||
componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario, marca_componente)
|
||||
`)
|
||||
.eq('of_number', ofNumber);
|
||||
|
||||
@@ -158,20 +178,24 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
|
||||
console.log(`PROGRESSO GERAL: ${pesoTotalFabricado} kg / ${pesoTotalPlanejado} kg = ${progressoGeral.toFixed(2)}%`);
|
||||
|
||||
// 7. Buscar range de datas
|
||||
const { data: dateRangeData, error: dateRangeError } = await supabase
|
||||
.rpc('get_dashboard_date_range', { of_number_param: ofNumber });
|
||||
|
||||
if (dateRangeError) {
|
||||
console.error('Erro ao buscar range de datas:', dateRangeError);
|
||||
// 7. Buscar range de datas (opcional, ignora erro se a RPC não existir no Supabase)
|
||||
let dateRangeData = null;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.rpc('get_dashboard_date_range', { of_number_param: ofNumber });
|
||||
if (!error) dateRangeData = data;
|
||||
} catch (e) {
|
||||
// Ignora caso RPC não exista
|
||||
}
|
||||
|
||||
// 8. Buscar dados consolidados
|
||||
const { data: consolidatedData, error: consolidatedError } = await supabase
|
||||
.rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber });
|
||||
|
||||
if (consolidatedError) {
|
||||
console.error('Erro ao buscar dados consolidados:', consolidatedError);
|
||||
// 8. Buscar dados consolidados (opcional, ignora erro se a RPC não existir no Supabase)
|
||||
let consolidatedData = null;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber });
|
||||
if (!error) consolidatedData = data;
|
||||
} catch (e) {
|
||||
// Ignora caso RPC não exista
|
||||
}
|
||||
|
||||
// 9. Buscar processos
|
||||
@@ -207,7 +231,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
if (!cronogramaOf?.processos_cronograma) return 0;
|
||||
|
||||
const hoje = new Date();
|
||||
let processoCronograma = null;
|
||||
let processoCronograma;
|
||||
|
||||
// Mapear nomes dos processos para os do cronograma
|
||||
if (nomeProcesso.toLowerCase().includes('corte') || nomeProcesso.toLowerCase().includes('solda')) {
|
||||
@@ -291,7 +315,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
const progressoEsperado = calcularProgressoEsperado(processo.nome);
|
||||
|
||||
// Determinar status baseado na comparação entre progresso real e esperado
|
||||
let status: 'verde' | 'amarelo' | 'vermelho' | 'azul' = 'verde';
|
||||
let status: 'verde' | 'amarelo' | 'vermelho' | 'azul';
|
||||
|
||||
// Se o progresso esperado chegou a 100% (passou da data fim), usar cor vermelha na barra
|
||||
if (progressoEsperado >= 100) {
|
||||
@@ -330,7 +354,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
let dataFimProcesso: Date | null = null;
|
||||
|
||||
if (cronogramaOf?.processos_cronograma) {
|
||||
let processoCronograma = null;
|
||||
let processoCronograma;
|
||||
|
||||
// Mapear nomes dos processos para os do cronograma
|
||||
if (processo.nome.toLowerCase().includes('corte') || processo.nome.toLowerCase().includes('solda')) {
|
||||
@@ -405,9 +429,13 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
}
|
||||
// Antes do início planejado, o planejado fica 0
|
||||
|
||||
// Calcular progresso real até esta data - soma dos pesos apontados
|
||||
const apontamentosAteData = apontamentosProcesso.filter(a => a.data_apontamento <= dataStr);
|
||||
let realizado = 0;
|
||||
// Calcular progresso real até esta data - soma dos pesos apontados
|
||||
const apontamentosAteData = apontamentosProcesso.filter(a => {
|
||||
if (!a.data_apontamento) return false;
|
||||
const dataAptStr = typeof a.data_apontamento === 'string' ? a.data_apontamento.substring(0, 10) : '';
|
||||
return dataAptStr <= dataStr;
|
||||
});
|
||||
let realizado = 0;
|
||||
apontamentosAteData.forEach(a => {
|
||||
let pesoUnitario = 0;
|
||||
|
||||
@@ -524,11 +552,11 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [ofNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, [ofNumber]);
|
||||
}, [fetchDashboardData]);
|
||||
|
||||
return {
|
||||
dashboardData,
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface FichaTecnicaData {
|
||||
doc_catalogo?: boolean;
|
||||
doc_fotos?: boolean;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Informações do projeto
|
||||
info_calculo_estrutural?: any;
|
||||
info_projeto_basico?: any;
|
||||
@@ -144,6 +145,7 @@ export interface FichaTecnicaData {
|
||||
visto_exp?: string;
|
||||
visto_qual?: string;
|
||||
visto_colunas?: any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
export function useFichaTecnica() {
|
||||
@@ -160,7 +162,6 @@ export function useFichaTecnica() {
|
||||
const { data, error } = await supabase
|
||||
.from('ficha_tecnica_contratos')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('of_number', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
@@ -182,7 +183,6 @@ export function useFichaTecnica() {
|
||||
.from('ficha_tecnica_contratos')
|
||||
.select('*')
|
||||
.eq('of_number', ofNumber.trim())
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
@@ -373,8 +373,7 @@ export function useFichaTecnica() {
|
||||
const { error } = await supabase
|
||||
.from('ficha_tecnica_contratos')
|
||||
.update(dataToSave)
|
||||
.eq('id', data.id)
|
||||
.eq('user_id', user.id);
|
||||
.eq('id', data.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao atualizar ficha técnica:', error);
|
||||
@@ -389,7 +388,6 @@ export function useFichaTecnica() {
|
||||
.from('ficha_tecnica_contratos')
|
||||
.select('id')
|
||||
.eq('of_number', data.of_number.trim())
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingFicha) {
|
||||
@@ -397,8 +395,7 @@ export function useFichaTecnica() {
|
||||
const { error } = await supabase
|
||||
.from('ficha_tecnica_contratos')
|
||||
.update(dataToSave)
|
||||
.eq('id', existingFicha.id)
|
||||
.eq('user_id', user.id);
|
||||
.eq('id', existingFicha.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao atualizar ficha técnica existente:', error);
|
||||
|
||||
@@ -209,16 +209,17 @@ export function useInterfaceResources() {
|
||||
.from('profiles')
|
||||
.select('privilege_id')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
.limit(1);
|
||||
|
||||
if (profileError) throw profileError;
|
||||
|
||||
if (!profile?.privilege_id) return [];
|
||||
const priv = profile && profile.length > 0 ? profile[0].privilege_id : null;
|
||||
if (!priv) return [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('privilege_interface_resources')
|
||||
.select('resource_key')
|
||||
.eq('privilege_id', profile.privilege_id);
|
||||
.eq('privilege_id', priv);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
|
||||
@@ -44,14 +44,14 @@ export const useItensPrioridadeFabricacao = () => {
|
||||
.from('itens_prioridade_fabricacao')
|
||||
.select(`
|
||||
*,
|
||||
peca:pecas(marca, descricao, peso_unitario, quantidade, tem_componentes, of_number, etapa_fase),
|
||||
prioridade_fabricacao:prioridades_fabricacao(
|
||||
peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(marca, descricao, peso_unitario, quantidade, tem_componentes, of_number, etapa_fase),
|
||||
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(
|
||||
of_number,
|
||||
etapa_fase,
|
||||
revisao,
|
||||
data_ultima_modificacao,
|
||||
modificado_por,
|
||||
prioridade_config:prioridades_config(codigo, nome, cor)
|
||||
prioridade_config:prioridades_config!prioridades_fabricacao_prioridade_id_fkey(codigo, nome, cor)
|
||||
)
|
||||
`)
|
||||
.order('ordem_fabricacao', { ascending: true });
|
||||
@@ -124,8 +124,8 @@ export const useItensPrioridadeFabricacao = () => {
|
||||
.from('itens_prioridade_fabricacao')
|
||||
.select(`
|
||||
*,
|
||||
peca:pecas(of_number, etapa_fase, marca),
|
||||
prioridade_fabricacao:prioridades_fabricacao(of_number, etapa_fase)
|
||||
peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(of_number, etapa_fase, marca),
|
||||
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(of_number, etapa_fase)
|
||||
`)
|
||||
.eq('id', itemId)
|
||||
.single();
|
||||
@@ -215,8 +215,8 @@ export const useItensPrioridadeFabricacao = () => {
|
||||
.select(`
|
||||
id,
|
||||
prioridade_fabricacao_id,
|
||||
peca:pecas(of_number, etapa_fase, marca),
|
||||
prioridade_fabricacao:prioridades_fabricacao(of_number, etapa_fase)
|
||||
peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(of_number, etapa_fase, marca),
|
||||
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(of_number, etapa_fase)
|
||||
`)
|
||||
.in('id', idsParaRemover);
|
||||
|
||||
@@ -341,7 +341,7 @@ export const useItensPrioridadeFabricacao = () => {
|
||||
.from('itens_prioridade_fabricacao')
|
||||
.select(`
|
||||
*,
|
||||
prioridade_fabricacao:prioridades_fabricacao(
|
||||
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(
|
||||
of_number,
|
||||
etapa_fase
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
|
||||
.select(`
|
||||
revisao,
|
||||
data_ultima_modificacao,
|
||||
profiles:modificado_por(full_name)
|
||||
profiles:prioridades_fabricacao_modificado_por_profiles_fkey(full_name)
|
||||
`)
|
||||
.eq('of_number', ofSelecionada)
|
||||
.eq('etapa_fase', faseSelecionada)
|
||||
@@ -71,7 +71,7 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
|
||||
setVersaoAtual({
|
||||
revisao: data.revisao || 0,
|
||||
dataModificacao: data.data_ultima_modificacao || new Date().toISOString(),
|
||||
modificadoPor: (data.profiles as any)?.full_name
|
||||
modificadoPor: (data.profiles as { full_name?: string } | null)?.full_name
|
||||
});
|
||||
} else {
|
||||
setVersaoAtual({
|
||||
@@ -92,12 +92,49 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
|
||||
setFaseSelecionada(novaFase);
|
||||
};
|
||||
|
||||
const incrementarRevisao = async () => {
|
||||
if (!ofSelecionada || !faseSelecionada) return false;
|
||||
|
||||
try {
|
||||
const novaRevisao = (versaoAtual?.revisao || 0) + 1;
|
||||
const dataModificacao = new Date().toISOString();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('prioridades_fabricacao')
|
||||
.update({
|
||||
revisao: novaRevisao,
|
||||
data_ultima_modificacao: dataModificacao
|
||||
})
|
||||
.eq('of_number', ofSelecionada)
|
||||
.eq('etapa_fase', faseSelecionada);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao incrementar revisão no DB:', error);
|
||||
toast.error('Erro ao registrar nova revisão.');
|
||||
return false;
|
||||
}
|
||||
|
||||
setVersaoAtual(prev => ({
|
||||
...prev!,
|
||||
revisao: novaRevisao,
|
||||
dataModificacao: dataModificacao
|
||||
}));
|
||||
|
||||
toast.success(`Revisão atualizada para ${novaRevisao}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao incrementar revisão:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...hookOriginal,
|
||||
itensPorPrioridade: itensFiltrados,
|
||||
ofSelecionada,
|
||||
faseSelecionada,
|
||||
versaoAtual,
|
||||
onFiltroChange: handleFiltroChange
|
||||
onFiltroChange: handleFiltroChange,
|
||||
incrementarRevisao
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,17 +14,29 @@ export const useOFsAtivas = () => {
|
||||
queryKey: ['ofs-ativas'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('ficha_tecnica_contratos')
|
||||
.select('of_number, descricao_resumida, cliente, gestor')
|
||||
.not('of_number', 'is', null)
|
||||
.order('of_number');
|
||||
.from('ordens_fabricacao')
|
||||
.select(`
|
||||
num_of,
|
||||
descritivo,
|
||||
gestor,
|
||||
ficha_tecnica_contratos (
|
||||
cliente
|
||||
)
|
||||
`)
|
||||
.eq('status', 'ativa')
|
||||
.order('num_of');
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao buscar OFs ativas:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as OFAtiva[];
|
||||
return data.map((of: { num_of: string, descritivo?: string | null, gestor?: string | null, ficha_tecnica_contratos?: { cliente?: string | null } | null }) => ({
|
||||
of_number: of.num_of,
|
||||
descricao_resumida: of.descritivo,
|
||||
gestor: of.gestor,
|
||||
cliente: of.ficha_tecnica_contratos?.cliente
|
||||
})) as OFAtiva[];
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,90 +1,35 @@
|
||||
|
||||
// Password reset usando Logto (substitui supabase.auth.resetPasswordForEmail)
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { requestPasswordReset as logtoRequestPasswordReset } from '@/lib/logto/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const usePasswordReset = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Mutation para solicitar redefinição de senha
|
||||
const requestPasswordReset = useMutation({
|
||||
mutationFn: async ({ email }: { email: string }) => {
|
||||
console.log('🔄 Solicitando redefinição de senha para:', email);
|
||||
console.log('🔄 [Logto] Solicitando redefinição de senha para:', email);
|
||||
|
||||
// Detectar o domínio atual e configurar URL de redirecionamento
|
||||
const currentHostname = window.location.hostname;
|
||||
let redirectUrl = '';
|
||||
|
||||
if (currentHostname.includes('preview--tracksteel.lovable.app') ||
|
||||
currentHostname.includes('lovableproject.com') ||
|
||||
currentHostname.includes('lovable.app')) {
|
||||
// Ambiente de preview do Lovable
|
||||
redirectUrl = `${window.location.origin}/auth?type=recovery`;
|
||||
} else if (currentHostname.includes('tracksteel.com.br')) {
|
||||
// Domínio customizado
|
||||
redirectUrl = `https://app.tracksteel.com.br/auth?type=recovery`;
|
||||
} else {
|
||||
// Fallback para localhost ou outros casos
|
||||
redirectUrl = `${window.location.origin}/auth?type=recovery`;
|
||||
}
|
||||
|
||||
console.log('📍 URL de redirecionamento configurada:', redirectUrl);
|
||||
|
||||
// Enviar e-mail de redefinição usando o Supabase Auth
|
||||
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: redirectUrl,
|
||||
});
|
||||
|
||||
if (resetError) {
|
||||
console.error('❌ Erro ao solicitar redefinição:', resetError);
|
||||
throw resetError;
|
||||
}
|
||||
|
||||
console.log('✅ E-mail de redefinição enviado com sucesso para:', email);
|
||||
|
||||
// Registrar a solicitação no banco de dados (opcional, não bloquear se falhar)
|
||||
try {
|
||||
const { error: insertError } = await supabase
|
||||
.from('password_reset_requests')
|
||||
.insert({
|
||||
email,
|
||||
user_id: null,
|
||||
ip_address: null,
|
||||
user_agent: navigator.userAgent,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
console.warn('⚠️ Falha ao registrar solicitação:', insertError);
|
||||
} else {
|
||||
console.log('📝 Solicitação registrada no banco de dados');
|
||||
}
|
||||
} catch (logError) {
|
||||
console.warn('⚠️ Erro no log da solicitação:', logError);
|
||||
const result = await logtoRequestPasswordReset(email);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || 'Falha ao solicitar reset');
|
||||
}
|
||||
|
||||
console.log('✅ [Logto] E-mail de redefinição processado para:', email);
|
||||
return { success: true };
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('E-mail de redefinição enviado com sucesso! Verifique sua caixa de entrada e clique no link recebido.');
|
||||
toast.success(
|
||||
'Se o e-mail estiver cadastrado, você receberá um link para redefinir sua senha em alguns minutos.'
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['password-reset-requests'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('❌ Erro na solicitação de redefinição:', error);
|
||||
|
||||
// Tratamento específico de erros
|
||||
let errorMessage = 'Erro ao solicitar redefinição de senha. Tente novamente.';
|
||||
|
||||
if (error.message?.includes('User not found')) {
|
||||
errorMessage = 'E-mail não encontrado no sistema. Verifique se o e-mail está correto.';
|
||||
} else if (error.message?.includes('rate limit') || error.message?.includes('Email rate limit exceeded')) {
|
||||
errorMessage = 'Muitas tentativas de redefinição. Aguarde alguns minutos antes de tentar novamente.';
|
||||
} else if (error.message?.includes('signup_disabled')) {
|
||||
errorMessage = 'Funcionalidade temporariamente indisponível. Contate o administrador.';
|
||||
} else if (error.message) {
|
||||
errorMessage = `Erro: ${error.message}`;
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
onError: (error: Error) => {
|
||||
console.error('❌ Erro ao solicitar reset:', error);
|
||||
// Não revela se o email existe (segurança)
|
||||
toast.error(
|
||||
'Não foi possível enviar o e-mail. Tente novamente em alguns minutos.'
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -363,8 +363,8 @@ export function usePecas() {
|
||||
marca: item.marca || '',
|
||||
descricao: item.descricao || '',
|
||||
quantidade: Number(item.quantidade) || 0,
|
||||
peso_unitario: Math.round(Number(item.peso_unitario) || 0),
|
||||
peso_total: Math.round(Number(item.peso_total) || 0),
|
||||
peso_unitario: Number(item.peso_unitario) || 0,
|
||||
peso_total: Number(item.peso_total) || 0,
|
||||
tratamento_superficial: item.tratamento_superficial || '',
|
||||
material: item.material || '',
|
||||
perfil_principal: item.perfil_principal || '',
|
||||
|
||||
@@ -177,7 +177,10 @@ export const usePecasTable = (pecas: Peca[]) => {
|
||||
}
|
||||
|
||||
return pecasParaCalcular.reduce((total, peca) => {
|
||||
return total + (peca.peso_total || 0);
|
||||
const pTotal = (peca.peso_total && peca.peso_total > 0)
|
||||
? peca.peso_total
|
||||
: (peca.quantidade || 1) * (peca.peso_unitario || 0);
|
||||
return total + pTotal;
|
||||
}, 0);
|
||||
}, [filteredAndSortedPecas, selectedPecas]);
|
||||
|
||||
|
||||
@@ -44,71 +44,72 @@ export const useProcessChartData = (ofNumber: string) => {
|
||||
console.log(`Processo ${processName} encontrado com ID:`, processo.id);
|
||||
|
||||
const hoje = new Date();
|
||||
hoje.setHours(23, 59, 59, 999);
|
||||
|
||||
const seteDiasAtras = new Date(hoje);
|
||||
seteDiasAtras.setDate(hoje.getDate() - 7);
|
||||
seteDiasAtras.setHours(0, 0, 0, 0);
|
||||
|
||||
const quatorzeDiasAtras = new Date(hoje);
|
||||
quatorzeDiasAtras.setDate(hoje.getDate() - 14);
|
||||
quatorzeDiasAtras.setHours(0, 0, 0, 0);
|
||||
|
||||
const seisDiasAtras = new Date(hoje);
|
||||
seisDiasAtras.setDate(hoje.getDate() - 6);
|
||||
seisDiasAtras.setHours(0, 0, 0, 0);
|
||||
|
||||
// Buscar apontamentos dos últimos 14 dias
|
||||
// Buscar apontamentos completos para obter o total fabricado
|
||||
const { data: apontamentos, error: apontamentosError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
*,
|
||||
peca:pecas(peso_unitario),
|
||||
componente:componentes_peca(peso_unitario)
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
|
||||
componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario)
|
||||
`)
|
||||
.eq('of_number', ofNumber)
|
||||
.eq('processo_id', processo.id)
|
||||
.gte('data_apontamento', quatorzeDiasAtras.toISOString().split('T')[0])
|
||||
.lte('data_apontamento', hoje.toISOString().split('T')[0]);
|
||||
.eq('processo_id', processo.id);
|
||||
|
||||
if (apontamentosError) {
|
||||
console.error(`Erro ao buscar apontamentos para ${processName}:`, apontamentosError);
|
||||
return [
|
||||
{ name: '7-15d', weight: 0 },
|
||||
{ name: 'ult.7d', weight: 0 },
|
||||
{ name: 'prev.7d', weight: 0 }
|
||||
];
|
||||
}
|
||||
|
||||
console.log(`Apontamentos encontrados para ${processName}:`, apontamentos?.length || 0);
|
||||
|
||||
if (!apontamentos || apontamentos.length === 0) {
|
||||
return [
|
||||
{ name: '7-15d', weight: 0 },
|
||||
{ name: 'ult.7d', weight: 0 },
|
||||
{ name: 'prev.7d', weight: 0 }
|
||||
];
|
||||
}
|
||||
|
||||
// Calcular pesos por período
|
||||
let peso7a14Dias = 0;
|
||||
let peso6DiasRecentes = 0;
|
||||
let pesoTotalProcessado = 0;
|
||||
|
||||
apontamentos.forEach(apontamento => {
|
||||
let pesoUnitario = 0;
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
|
||||
pesoUnitario = apontamento.componente.peso_unitario;
|
||||
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
|
||||
pesoUnitario = apontamento.peca.peso_unitario;
|
||||
}
|
||||
if (apontamentos && apontamentos.length > 0) {
|
||||
apontamentos.forEach(apontamento => {
|
||||
let pesoUnitario = 0;
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
|
||||
pesoUnitario = apontamento.componente.peso_unitario;
|
||||
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
|
||||
pesoUnitario = apontamento.peca.peso_unitario;
|
||||
}
|
||||
|
||||
const pesoTotal = pesoUnitario * apontamento.quantidade_produzida;
|
||||
const dataApontamento = new Date(apontamento.data_apontamento);
|
||||
const pesoTotal = pesoUnitario * apontamento.quantidade_produzida;
|
||||
pesoTotalProcessado += pesoTotal;
|
||||
|
||||
// Período de 7-14 dias atrás
|
||||
if (dataApontamento >= quatorzeDiasAtras && dataApontamento < seteDiasAtras) {
|
||||
peso7a14Dias += pesoTotal;
|
||||
}
|
||||
// Últimos 6 dias
|
||||
if (dataApontamento >= seisDiasAtras && dataApontamento <= hoje) {
|
||||
peso6DiasRecentes += pesoTotal;
|
||||
}
|
||||
});
|
||||
if (apontamento.data_apontamento) {
|
||||
// Se data_apontamento for apenas a data (YYYY-MM-DD), setamos pra meio dia pra evitar bugs de fuso
|
||||
const dateStr = typeof apontamento.data_apontamento === 'string' && apontamento.data_apontamento.length <= 10
|
||||
? apontamento.data_apontamento + 'T12:00:00'
|
||||
: apontamento.data_apontamento;
|
||||
|
||||
const dataApontamento = new Date(dateStr);
|
||||
|
||||
// Período de 7-14 dias atrás
|
||||
if (dataApontamento >= quatorzeDiasAtras && dataApontamento < seteDiasAtras) {
|
||||
peso7a14Dias += pesoTotal;
|
||||
}
|
||||
// Últimos 6 dias
|
||||
if (dataApontamento >= seisDiasAtras && dataApontamento <= hoje) {
|
||||
peso6DiasRecentes += pesoTotal;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Calcular peso necessário para próximos 7 dias (meta)
|
||||
const { data: ofData } = await supabase
|
||||
@@ -117,12 +118,38 @@ export const useProcessChartData = (ofNumber: string) => {
|
||||
.eq('num_of', ofNumber)
|
||||
.single();
|
||||
|
||||
let pesoTotalPlanejado = ofData?.peso_total || 0;
|
||||
|
||||
if (!pesoTotalPlanejado) {
|
||||
const { data: fichaTecnica } = await supabase
|
||||
.from('ficha_tecnica_contratos')
|
||||
.select('quantidade')
|
||||
.eq('of_number', ofNumber)
|
||||
.maybeSingle();
|
||||
|
||||
if (fichaTecnica?.quantidade) {
|
||||
pesoTotalPlanejado = fichaTecnica.quantidade;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pesoTotalPlanejado) {
|
||||
const { data: pecasData } = await supabase
|
||||
.from('pecas')
|
||||
.select('peso_unitario, quantidade')
|
||||
.eq('of_number', ofNumber);
|
||||
|
||||
if (pecasData) {
|
||||
pesoTotalPlanejado = pecasData.reduce((total, peca) => {
|
||||
return total + ((peca.peso_unitario || 0) * (peca.quantidade || 0));
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let pesoMeta = 0;
|
||||
if (ofData?.peso_total && ofData?.data_prazo) {
|
||||
const dataFim = new Date(ofData.data_prazo);
|
||||
if (pesoTotalPlanejado > 0) {
|
||||
const dataFim = ofData?.data_prazo ? new Date(ofData.data_prazo + 'T23:59:59') : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
const diasRestantes = Math.max(1, Math.ceil((dataFim.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24)));
|
||||
const pesoTotalProcessado = peso7a14Dias + peso6DiasRecentes;
|
||||
const pesoRestante = Math.max(0, ofData.peso_total - pesoTotalProcessado);
|
||||
const pesoRestante = Math.max(0, pesoTotalPlanejado - pesoTotalProcessado);
|
||||
|
||||
// Meta para próximos 7 dias baseada no ritmo necessário
|
||||
if (diasRestantes <= 7) {
|
||||
@@ -133,7 +160,7 @@ export const useProcessChartData = (ofNumber: string) => {
|
||||
}
|
||||
|
||||
const resultado = [
|
||||
{ name: '7-15d', weight: Number(peso7a14Dias.toFixed(0)) }, // Mantido em Kg
|
||||
{ name: '7-15d', weight: Number(peso7a14Dias.toFixed(0)) },
|
||||
{ name: 'ult.7d', weight: Number(peso6DiasRecentes.toFixed(0)) },
|
||||
{ name: 'prev.7d', weight: Number(pesoMeta.toFixed(0)) }
|
||||
];
|
||||
|
||||
@@ -45,20 +45,20 @@ export const useRelatorioDiario = (selectedDate: string) => {
|
||||
data_apontamento,
|
||||
created_at,
|
||||
tipo_apontamento,
|
||||
peca:pecas(
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(
|
||||
id,
|
||||
marca,
|
||||
descricao,
|
||||
etapa_fase,
|
||||
peso_unitario
|
||||
),
|
||||
componente:componentes_peca(
|
||||
componente:componentes_peca!apontamentos_producao_componente_id_fkey(
|
||||
id,
|
||||
marca_componente,
|
||||
descricao,
|
||||
peso_unitario
|
||||
),
|
||||
processo:processos_fabricacao(
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(
|
||||
nome
|
||||
)
|
||||
`)
|
||||
@@ -136,6 +136,7 @@ export const useRelatorioDiario = (selectedDate: string) => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchApontamentos();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDate]);
|
||||
|
||||
// Calcular resumo por processo
|
||||
|
||||
@@ -94,7 +94,7 @@ export const useRelatorioPecasProcesso = (ofNumber: string) => {
|
||||
peca_id,
|
||||
processo_id,
|
||||
quantidade_produzida,
|
||||
processos_fabricacao!inner(nome)
|
||||
processos_fabricacao!apontamentos_producao_processo_id_fkey(nome)
|
||||
`)
|
||||
.eq('of_number', ofNumber)
|
||||
.in('peca_id', pecaIds);
|
||||
|
||||
@@ -296,12 +296,26 @@ export const useSessionLogs = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Atualizar automaticamente usuários online
|
||||
// Atualizar automaticamente usuários online e fazer heartbeat da sessão
|
||||
useEffect(() => {
|
||||
fetchOnlineUsers();
|
||||
const interval = setInterval(fetchOnlineUsers, 30000); // Atualizar a cada 30 segundos
|
||||
const interval = setInterval(() => {
|
||||
fetchOnlineUsers();
|
||||
|
||||
const sessionId = currentSessionId || localStorage.getItem('currentSessionId');
|
||||
if (sessionId) {
|
||||
// Heartbeat pings database every 30s to keep session alive
|
||||
supabase
|
||||
.from('user_session_logs')
|
||||
.update({ updated_at: new Date().toISOString() })
|
||||
.eq('id', sessionId)
|
||||
.then(({ error }) => {
|
||||
if (error) console.error('Heartbeat error:', error);
|
||||
});
|
||||
}
|
||||
}, 30000); // Atualizar a cada 30 segundos
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [currentSessionId]);
|
||||
|
||||
// Gerenciar sessão baseado no estado de autenticação
|
||||
useEffect(() => {
|
||||
@@ -310,6 +324,7 @@ export const useSessionLogs = () => {
|
||||
} else if (!user && currentSessionId) {
|
||||
endSession();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user]);
|
||||
|
||||
// Finalizar sessão quando a janela é fechada
|
||||
|
||||
+23
-9
@@ -4,9 +4,9 @@ import { supabase } from '@/integrations/supabase/client';
|
||||
import { Database } from '@/integrations/supabase/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type Task = Database['public']['Tables']['tasks']['Row'];
|
||||
type TaskInsert = Database['public']['Tables']['tasks']['Insert'];
|
||||
type TaskUpdate = Database['public']['Tables']['tasks']['Update'];
|
||||
type Task = Database['TS_ERP']['Tables']['tasks']['Row'];
|
||||
type TaskInsert = Database['TS_ERP']['Tables']['tasks']['Insert'];
|
||||
type TaskUpdate = Database['TS_ERP']['Tables']['tasks']['Update'];
|
||||
|
||||
export const useTasks = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -182,17 +182,28 @@ export const useTasks = () => {
|
||||
queryFn: async () => {
|
||||
console.log('🔍 Fetching available OFs...');
|
||||
const { data, error } = await supabase
|
||||
.from('ficha_tecnica_contratos')
|
||||
.select('of_number, cliente')
|
||||
.order('of_number');
|
||||
.from('ordens_fabricacao')
|
||||
.select(`
|
||||
num_of,
|
||||
ficha_tecnica_contratos (
|
||||
cliente
|
||||
)
|
||||
`)
|
||||
.eq('status', 'ativa')
|
||||
.order('num_of');
|
||||
|
||||
if (error) {
|
||||
console.error('❌ Error fetching OFs:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('✅ Available OFs fetched:', data?.length || 0);
|
||||
return data || [];
|
||||
const mappedData = data?.map((of: { num_of: string, ficha_tecnica_contratos?: { cliente?: string | null } | null }) => ({
|
||||
of_number: of.num_of,
|
||||
cliente: of.ficha_tecnica_contratos?.cliente
|
||||
}));
|
||||
|
||||
console.log('✅ Available OFs fetched:', mappedData?.length || 0);
|
||||
return mappedData || [];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -213,6 +224,7 @@ export const useTasks = () => {
|
||||
|
||||
try {
|
||||
// Use RPC function to bypass RLS restrictions for user listing
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error } = await supabase.rpc('get_all_users_for_tasks' as any);
|
||||
|
||||
if (error) {
|
||||
@@ -248,7 +260,9 @@ export const useTasks = () => {
|
||||
|
||||
if (fallbackError) {
|
||||
console.error('❌ Error fetching users (fallback):', fallbackError);
|
||||
throw new Error('Erro ao buscar usuários: ' + fallbackError.message);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - TS expects 1 arg, eslint expects 2
|
||||
throw new Error('Erro ao buscar usuários: ' + fallbackError.message, { cause: rpcError as Error });
|
||||
}
|
||||
|
||||
console.log('✅ Available users fetched (fallback):', fallbackData?.length || 0);
|
||||
|
||||
@@ -11,6 +11,7 @@ export const useTasksEnhanced = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
// Helper function to apply filters to a query
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const applyFilters = (query: any, filters: TaskFilters) => {
|
||||
if (filters.of_number) {
|
||||
query = query.eq('of_number', filters.of_number);
|
||||
@@ -33,14 +34,16 @@ export const useTasksEnhanced = () => {
|
||||
if (filters.due_date_range) {
|
||||
const now = new Date();
|
||||
switch (filters.due_date_range) {
|
||||
case 'week':
|
||||
case 'week': {
|
||||
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
query = query.gte('due_date', now.toISOString()).lte('due_date', nextWeek.toISOString());
|
||||
break;
|
||||
case 'month':
|
||||
}
|
||||
case 'month': {
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
query = query.gte('due_date', now.toISOString()).lte('due_date', nextMonth.toISOString());
|
||||
break;
|
||||
}
|
||||
case 'overdue':
|
||||
query = query.lt('due_date', now.toISOString());
|
||||
break;
|
||||
@@ -276,7 +279,9 @@ export const useTasksEnhanced = () => {
|
||||
|
||||
if (fallbackError) {
|
||||
console.error('❌ Error fetching users (fallback):', fallbackError);
|
||||
throw new Error('Erro ao buscar usuários: ' + fallbackError.message);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - TS expects 1 arg, eslint expects 2
|
||||
throw new Error('Erro ao buscar usuários: ' + fallbackError.message, { cause: fallbackError });
|
||||
}
|
||||
|
||||
console.log('✅ Available users fetched (fallback):', fallbackData?.length || 0);
|
||||
@@ -316,17 +321,28 @@ export const useTasksEnhanced = () => {
|
||||
if (!user) throw new Error('Usuário não autenticado');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('ficha_tecnica_contratos')
|
||||
.select('of_number, cliente')
|
||||
.order('of_number');
|
||||
.from('ordens_fabricacao')
|
||||
.select(`
|
||||
num_of,
|
||||
ficha_tecnica_contratos (
|
||||
cliente
|
||||
)
|
||||
`)
|
||||
.eq('status', 'ativa')
|
||||
.order('num_of');
|
||||
|
||||
if (error) {
|
||||
console.error('❌ Error fetching OFs:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('✅ Available OFs fetched:', data?.length || 0);
|
||||
return data || [];
|
||||
const mappedData = data?.map((of: { num_of: string, ficha_tecnica_contratos?: { cliente?: string | null } | null }) => ({
|
||||
of_number: of.num_of,
|
||||
cliente: of.ficha_tecnica_contratos?.cliente
|
||||
}));
|
||||
|
||||
console.log('✅ Available OFs fetched:', mappedData?.length || 0);
|
||||
return mappedData || [];
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface UserProfile {
|
||||
privileges?: {
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: any;
|
||||
permissions: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface UserPrivilege {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
permissions: any;
|
||||
permissions: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -51,6 +51,33 @@ export interface UserDependency {
|
||||
}
|
||||
|
||||
export function useUserManagement() {
|
||||
const deleteUserAndLogtoAccount = async (userId: string, email: string) => {
|
||||
try {
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
|
||||
// 1. Deleta do Logto primeiro
|
||||
const response = await fetch(`${supabaseUrl}/functions/v1/delete-logto-user`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to delete from Logto", await response.text());
|
||||
toast.error("Erro ao excluir do Logto. A exclusão no sistema continuará.");
|
||||
}
|
||||
|
||||
// 2. Deleta do Supabase
|
||||
return await deleteUser(userId, true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Erro na comunicação com o servidor de autenticação");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState<UserProfile[]>([]);
|
||||
const [pendingUsers, setPendingUsers] = useState<UserProfile[]>([]);
|
||||
@@ -171,6 +198,7 @@ export function useUserManagement() {
|
||||
try {
|
||||
const { data: result, error } = await supabase.rpc('admin_create_user', {
|
||||
user_email: data.email,
|
||||
_caller_id: user?.id,
|
||||
user_full_name: data.full_name || null,
|
||||
user_function_id: data.function_id || null,
|
||||
user_privilege_id: data.privilege_id || null
|
||||
@@ -181,10 +209,11 @@ export function useUserManagement() {
|
||||
toast.success('Usuário criado com sucesso! Senha padrão: 1234');
|
||||
fetchUsers();
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Error creating user:', error);
|
||||
|
||||
if (error.message?.includes('User with this email already exists')) {
|
||||
const err = error as Error;
|
||||
if (err.message?.includes('User with this email already exists')) {
|
||||
toast.error('Este e-mail já está em uso');
|
||||
} else if (error.message?.includes('Only admins can create new users')) {
|
||||
toast.error('Apenas administradores podem criar usuários');
|
||||
@@ -230,6 +259,7 @@ export function useUserManagement() {
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.rpc('admin_delete_user', {
|
||||
_caller_id: user?.id,
|
||||
_user_id: userId
|
||||
});
|
||||
|
||||
@@ -239,12 +269,13 @@ export function useUserManagement() {
|
||||
fetchUsers();
|
||||
fetchPendingUsers();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Error deleting user:', error);
|
||||
|
||||
if (error.message?.includes('Only admins can delete users')) {
|
||||
const err = error as Error;
|
||||
if (err.message?.includes('Only admins can delete users')) {
|
||||
toast.error('Apenas administradores podem excluir usuários');
|
||||
} else if (error.message?.includes('User cannot be deleted due to existing dependencies')) {
|
||||
} else if (err.message?.includes('User cannot be deleted due to existing dependencies')) {
|
||||
toast.error('Este usuário não pode ser excluído pois possui dados vinculados no sistema');
|
||||
} else {
|
||||
toast.error('Erro ao excluir usuário');
|
||||
@@ -309,10 +340,9 @@ export function useUserManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle user status
|
||||
const toggleUserStatus = async (userId: string, currentStatus: string) => {
|
||||
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
|
||||
await updateUser(userId, { status: newStatus as any });
|
||||
await updateUser(userId, { status: newStatus as UserProfile['status'] });
|
||||
};
|
||||
|
||||
// CRUD functions for Functions table
|
||||
@@ -367,7 +397,7 @@ export function useUserManagement() {
|
||||
};
|
||||
|
||||
// CRUD functions for Privileges table - Updated to handle interface resources
|
||||
const createPrivilege = async (data: { name: string; description?: string; permissions?: any }, resourceKeys: string[] = []) => {
|
||||
const createPrivilege = async (data: { name: string; description?: string; permissions?: Record<string, unknown> }, resourceKeys: string[] = []) => {
|
||||
try {
|
||||
const { data: newPrivilege, error } = await supabase
|
||||
.from('privileges')
|
||||
@@ -395,7 +425,7 @@ export function useUserManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const updatePrivilege = async (id: string, data: { name: string; description?: string; permissions?: any }) => {
|
||||
const updatePrivilege = async (id: string, data: { name: string; description?: string; permissions?: Record<string, unknown> }) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('privileges')
|
||||
@@ -458,6 +488,7 @@ export function useUserManagement() {
|
||||
updateUser,
|
||||
toggleUserStatus,
|
||||
deleteUser,
|
||||
deleteUserAndLogtoAccount,
|
||||
canDeleteUser,
|
||||
getUserDependencies,
|
||||
replaceUserWithDeleted,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
UserPermissions,
|
||||
@@ -14,7 +13,6 @@ import type {
|
||||
|
||||
export function useUserPermissions() {
|
||||
const { user } = useAuth();
|
||||
const { isAdmin } = useUserRole();
|
||||
const [userPermissions, setUserPermissions] = useState<UserPermissions>({
|
||||
can_admin: false,
|
||||
can_create_update_delete: false,
|
||||
@@ -88,10 +86,9 @@ export function useUserPermissions() {
|
||||
loadUserPermissions();
|
||||
}, [loadUserPermissions]);
|
||||
|
||||
// Check if user has access to a resource
|
||||
const hasAccess = useCallback((resourceKey?: string): boolean => {
|
||||
// Admin always has access
|
||||
if (isAdmin) {
|
||||
if (userPermissions.can_admin) {
|
||||
console.log(`✅ Admin access granted for resource: ${resourceKey || 'general'}`);
|
||||
return true;
|
||||
}
|
||||
@@ -124,7 +121,7 @@ export function useUserPermissions() {
|
||||
const hasFunctionalAccess = Object.values(userPermissions).some(Boolean);
|
||||
console.log(`🔄 Fallback functional access for ${resourceKey}: ${hasFunctionalAccess}`, userPermissions);
|
||||
return hasFunctionalAccess;
|
||||
}, [isAdmin, userPermissions, resourcePermissions]);
|
||||
}, [userPermissions, resourcePermissions]);
|
||||
|
||||
// Check if user can access a specific route
|
||||
const canAccessRoute = useCallback((route: string): boolean => {
|
||||
@@ -148,10 +145,9 @@ export function useUserPermissions() {
|
||||
return hasAccess();
|
||||
}, [hasAccess]);
|
||||
|
||||
// Get permission level for a specific resource
|
||||
const getResourcePermission = useCallback((resourceKey: ResourceKey): PermissionLevel => {
|
||||
// Admin always has full permissions
|
||||
if (isAdmin) return 'can_admin';
|
||||
if (userPermissions.can_admin) return 'can_admin';
|
||||
|
||||
// Check specific resource permission
|
||||
if (resourcePermissions[resourceKey]) {
|
||||
@@ -165,7 +161,7 @@ export function useUserPermissions() {
|
||||
if (userPermissions.can_view_only) return 'can_view_only';
|
||||
|
||||
return 'no_access';
|
||||
}, [isAdmin, userPermissions, resourcePermissions]);
|
||||
}, [userPermissions, resourcePermissions]);
|
||||
|
||||
// Check if user can perform a specific action on a resource
|
||||
const canPerformActionByResource = useCallback((
|
||||
@@ -190,9 +186,8 @@ export function useUserPermissions() {
|
||||
}
|
||||
}, [getResourcePermission]);
|
||||
|
||||
// Generic permission check for functional permissions
|
||||
const canPerformAction = useCallback((action: ActionType): boolean => {
|
||||
if (isAdmin) return true;
|
||||
if (userPermissions.can_admin) return true;
|
||||
|
||||
switch (action) {
|
||||
case 'admin':
|
||||
@@ -208,7 +203,7 @@ export function useUserPermissions() {
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [isAdmin, userPermissions]);
|
||||
}, [userPermissions]);
|
||||
|
||||
// Set specific resource permission
|
||||
const setResourcePermission = useCallback(async (
|
||||
|
||||
@@ -7,6 +7,7 @@ interface UserProfile {
|
||||
full_name: string | null;
|
||||
email: string | null;
|
||||
profile_image_url: string | null;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
export function useUserProfile() {
|
||||
@@ -21,6 +22,7 @@ export function useUserProfile() {
|
||||
setProfile(null);
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user]);
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
@@ -30,7 +32,7 @@ export function useUserProfile() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name, email, profile_image_url')
|
||||
.select('full_name, email, profile_image_url, status')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -39,18 +41,20 @@ export function useUserProfile() {
|
||||
} else {
|
||||
// Fallback para dados do usuário auth
|
||||
setProfile({
|
||||
full_name: user.user_metadata?.full_name || null,
|
||||
full_name: user.name || user.username || null,
|
||||
email: user.email || null,
|
||||
profile_image_url: null
|
||||
profile_image_url: null,
|
||||
status: null
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar perfil do usuário:', error);
|
||||
// Fallback para dados do usuário auth
|
||||
setProfile({
|
||||
full_name: user.user_metadata?.full_name || null,
|
||||
full_name: user.name || user.username || null,
|
||||
email: user.email || null,
|
||||
profile_image_url: null
|
||||
profile_image_url: null,
|
||||
status: null
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -18,6 +18,7 @@ export function useUserResourcePermissions(resourceKey: string) {
|
||||
const [users, setUsers] = useState<UserWithProfile[]>([]);
|
||||
const [resourcePermissions, setResourcePermissions] = useState<UserInterfacePermission[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const channelRef = useRef<any>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
@@ -66,19 +67,20 @@ export function useUserResourcePermissions(resourceKey: string) {
|
||||
// Enriquecer com dados do usuário
|
||||
const enrichedData = await Promise.all(
|
||||
(data || []).map(async (permission) => {
|
||||
const { data: profileData } = await supabase
|
||||
const { data: profileDataArr } = await supabase
|
||||
.from('profiles')
|
||||
.select('email, full_name')
|
||||
.eq('id', permission.user_id)
|
||||
.single();
|
||||
.limit(1);
|
||||
|
||||
const profileData = profileDataArr && profileDataArr.length > 0 ? profileDataArr[0] : null;
|
||||
return {
|
||||
user_id: permission.user_id,
|
||||
resource_key: permission.resource_key,
|
||||
permission_level: permission.permission as PermissionLevel,
|
||||
created_at: permission.created_at,
|
||||
updated_at: permission.updated_at,
|
||||
profiles: profileData
|
||||
profiles: profileData as any
|
||||
} as UserInterfacePermission;
|
||||
})
|
||||
);
|
||||
@@ -166,12 +168,13 @@ export function useUserResourcePermissions(resourceKey: string) {
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
const { data: userExists, error: userError } = await supabase
|
||||
const { data: userExistsArr, error: userError } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, email')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
.limit(1);
|
||||
|
||||
const userExists = userExistsArr && userExistsArr.length > 0 ? userExistsArr[0] : null;
|
||||
if (userError || !userExists) {
|
||||
console.error('User not found:', { userId, userError });
|
||||
toast.error('Usuário não encontrado');
|
||||
@@ -341,6 +344,7 @@ export function useUserResourcePermissions(resourceKey: string) {
|
||||
mountedRef.current = false;
|
||||
cleanupChannel();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, isAdmin, resourceKey]);
|
||||
|
||||
// Cleanup on unmount
|
||||
|
||||
+29
-67
@@ -1,84 +1,46 @@
|
||||
// Hook que retorna role/permissões do usuário
|
||||
// Migrado pra consumir as permissões reais do banco via useUserPermissions
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserPermissions } from '@/hooks/useUserPermissions';
|
||||
import type { UserRole, AppRole } from '@/hooks/useUserPermissions/types';
|
||||
|
||||
export type AccessLevel = 'Total' | 'Parcial' | 'Restrita';
|
||||
|
||||
export const useUserRole = () => {
|
||||
const { user } = useAuth();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { userPermissions, loading: permsLoading } = useUserPermissions();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['user-role', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!user?.id) {
|
||||
return {
|
||||
accessLevel: 'Restrita' as AccessLevel,
|
||||
isAdmin: false,
|
||||
isGerencia: false,
|
||||
isDiretoria: false,
|
||||
role: 'user' as UserRole
|
||||
};
|
||||
}
|
||||
const loading = authLoading || permsLoading;
|
||||
|
||||
try {
|
||||
const { data: profile, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, full_name')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
// Sem usuário = sem permissão
|
||||
if (!user) {
|
||||
return {
|
||||
accessLevel: 'Restrita' as AccessLevel,
|
||||
isAdmin: false,
|
||||
isGerencia: false,
|
||||
isDiretoria: false,
|
||||
role: 'user' as UserRole,
|
||||
loading,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (error || !profile) {
|
||||
return {
|
||||
accessLevel: 'Restrita' as AccessLevel,
|
||||
isAdmin: false,
|
||||
isGerencia: false,
|
||||
isDiretoria: false,
|
||||
role: 'user' as UserRole
|
||||
};
|
||||
}
|
||||
|
||||
// For now, return default permissions until we have proper role system
|
||||
// This can be enhanced later with actual role checking
|
||||
const accessLevel = 'Total' as AccessLevel;
|
||||
const isAdmin = true; // Temporary - should be based on actual roles
|
||||
|
||||
return {
|
||||
accessLevel,
|
||||
isAdmin: true,
|
||||
isGerencia: false,
|
||||
isDiretoria: false,
|
||||
role: 'admin' as UserRole
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar papel do usuário:', error);
|
||||
return {
|
||||
accessLevel: 'Restrita' as AccessLevel,
|
||||
isAdmin: false,
|
||||
isGerencia: false,
|
||||
isDiretoria: false,
|
||||
role: 'user' as UserRole
|
||||
};
|
||||
}
|
||||
},
|
||||
enabled: !!user?.id,
|
||||
});
|
||||
|
||||
// Extract the data and add loading/error handling
|
||||
const { data, isLoading, error } = query;
|
||||
// Com usuário autenticado + validações do json de privilégios do DB
|
||||
const isAdminCheck = Boolean(userPermissions.can_admin);
|
||||
|
||||
return {
|
||||
...data,
|
||||
loading: isLoading,
|
||||
error,
|
||||
// Also include the query object for compatibility
|
||||
...query
|
||||
accessLevel: isAdminCheck ? 'Total' : 'Parcial' as AccessLevel,
|
||||
isAdmin: isAdminCheck,
|
||||
isGerencia: isAdminCheck, // fallback para componentes legados
|
||||
isDiretoria: isAdminCheck, // fallback para componentes legados
|
||||
role: (isAdminCheck ? 'admin' : 'user') as UserRole,
|
||||
loading,
|
||||
error: null,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to check if user has role using correct AppRole type
|
||||
// Helper function (placeholder, não usado)
|
||||
export const hasRole = (userId: string | undefined, role: AppRole): boolean => {
|
||||
// Implementation would check against supabase function
|
||||
return false; // Placeholder
|
||||
return false;
|
||||
};
|
||||
@@ -41,8 +41,8 @@ export const useValidacaoSequencialProcessos = () => {
|
||||
quantidade_produzida,
|
||||
processo_id,
|
||||
tipo_apontamento,
|
||||
peca:pecas(marca),
|
||||
processo:processos_fabricacao(ordem)
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(marca),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(ordem)
|
||||
`)
|
||||
.eq('of_number', ofNumber)
|
||||
.eq('tipo_apontamento', 'peca');
|
||||
@@ -54,8 +54,8 @@ export const useValidacaoSequencialProcessos = () => {
|
||||
quantidade_produzida,
|
||||
processo_id,
|
||||
tipo_apontamento,
|
||||
componente:componentes_peca(marca_componente),
|
||||
processo:processos_fabricacao(ordem)
|
||||
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(ordem)
|
||||
`)
|
||||
.eq('of_number', ofNumber)
|
||||
.eq('tipo_apontamento', 'componente');
|
||||
@@ -163,15 +163,33 @@ export const useValidacaoSequencialProcessos = () => {
|
||||
return { valido: true };
|
||||
}, [historico, processos]);
|
||||
|
||||
// Função auxiliar para calcular quantidade disponível do processo anterior
|
||||
const calcularQuantidadeDisponivelProcessoAnterior = useCallback(async (
|
||||
marcaItem: string,
|
||||
ordemProcessoAnterior: number,
|
||||
ofNumber: string,
|
||||
tipoItem: 'peca' | 'componente'
|
||||
): Promise<number> => {
|
||||
const processoAnterior = processos.find(p => p.ordem === ordemProcessoAnterior);
|
||||
if (!processoAnterior) return 0;
|
||||
|
||||
return await buscarQuantidadeProcessada(
|
||||
marcaItem,
|
||||
processoAnterior.id,
|
||||
ofNumber,
|
||||
tipoItem
|
||||
);
|
||||
}, [processos, buscarQuantidadeProcessada]);
|
||||
|
||||
// Calcular itens disponíveis para um processo específico
|
||||
const calcularItensDisponiveis = useCallback(async (
|
||||
ofNumber: string,
|
||||
processoId: string,
|
||||
pecas: any[],
|
||||
componentes: any[]
|
||||
pecas: Record<string, unknown>[],
|
||||
componentes: Record<string, unknown>[]
|
||||
): Promise<{
|
||||
pecasDisponiveis: any[];
|
||||
componentesDisponiveis: any[];
|
||||
pecasDisponiveis: Record<string, unknown>[];
|
||||
componentesDisponiveis: Record<string, unknown>[];
|
||||
}> => {
|
||||
const processoAtual = processos.find(p => p.id === processoId);
|
||||
if (!processoAtual) {
|
||||
@@ -179,28 +197,31 @@ export const useValidacaoSequencialProcessos = () => {
|
||||
}
|
||||
|
||||
const ordemAtual = processoAtual.ordem;
|
||||
const pecasDisponiveis: any[] = [];
|
||||
const componentesDisponiveis: any[] = [];
|
||||
const pecasDisponiveis: Record<string, unknown>[] = [];
|
||||
const componentesDisponiveis: Record<string, unknown>[] = [];
|
||||
|
||||
// Processar peças
|
||||
for (const peca of pecas) {
|
||||
const marcaPeca = String(peca.marca || '');
|
||||
const qtdPeca = Number(peca.quantidade || 0);
|
||||
|
||||
if (ordemAtual === 1) {
|
||||
// Primeiro processo: todas as peças estão disponíveis
|
||||
pecasDisponiveis.push({
|
||||
...peca,
|
||||
quantidade_disponivel: peca.quantidade || 0
|
||||
quantidade_disponivel: qtdPeca
|
||||
});
|
||||
} else {
|
||||
// Calcular quantidade disponível baseada no processo anterior
|
||||
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
|
||||
peca.marca,
|
||||
marcaPeca,
|
||||
ordemAtual - 1,
|
||||
ofNumber,
|
||||
'peca'
|
||||
);
|
||||
|
||||
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
|
||||
peca.marca,
|
||||
marcaPeca,
|
||||
processoId,
|
||||
ofNumber,
|
||||
'peca'
|
||||
@@ -219,23 +240,26 @@ export const useValidacaoSequencialProcessos = () => {
|
||||
|
||||
// Processar componentes
|
||||
for (const componente of componentes) {
|
||||
const marcaComp = String(componente.marca_componente || '');
|
||||
const qtdComp = Number(componente.quantidade_total || 0);
|
||||
|
||||
if (ordemAtual === 1) {
|
||||
// Primeiro processo: todos os componentes estão disponíveis
|
||||
componentesDisponiveis.push({
|
||||
...componente,
|
||||
quantidade_disponivel: componente.quantidade_total || 0
|
||||
quantidade_disponivel: qtdComp
|
||||
});
|
||||
} else {
|
||||
// Calcular quantidade disponível baseada no processo anterior
|
||||
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
|
||||
componente.marca_componente,
|
||||
marcaComp,
|
||||
ordemAtual - 1,
|
||||
ofNumber,
|
||||
'componente'
|
||||
);
|
||||
|
||||
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
|
||||
componente.marca_componente,
|
||||
marcaComp,
|
||||
processoId,
|
||||
ofNumber,
|
||||
'componente'
|
||||
@@ -253,25 +277,7 @@ export const useValidacaoSequencialProcessos = () => {
|
||||
}
|
||||
|
||||
return { pecasDisponiveis, componentesDisponiveis };
|
||||
}, [processos, buscarQuantidadeProcessada]);
|
||||
|
||||
// Função auxiliar para calcular quantidade disponível do processo anterior
|
||||
const calcularQuantidadeDisponivelProcessoAnterior = async (
|
||||
marcaItem: string,
|
||||
ordemProcessoAnterior: number,
|
||||
ofNumber: string,
|
||||
tipoItem: 'peca' | 'componente'
|
||||
): Promise<number> => {
|
||||
const processoAnterior = processos.find(p => p.ordem === ordemProcessoAnterior);
|
||||
if (!processoAnterior) return 0;
|
||||
|
||||
return await buscarQuantidadeProcessada(
|
||||
marcaItem,
|
||||
processoAnterior.id,
|
||||
ofNumber,
|
||||
tipoItem
|
||||
);
|
||||
};
|
||||
}, [processos, buscarQuantidadeProcessada, calcularQuantidadeDisponivelProcessoAnterior]);
|
||||
|
||||
return {
|
||||
historico,
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// Client Logto puro (sem @logto/react, sem instalar pacotes)
|
||||
// Usa PKCE (Logto exige pra apps SPA)
|
||||
|
||||
const LOGTO_ENDPOINT = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
|
||||
const APP_ID = import.meta.env.VITE_LOGTO_APP_ID || '';
|
||||
const REDIRECT_URI =
|
||||
import.meta.env.VITE_LOGTO_REDIRECT_URI || window.location.origin + '/callback';
|
||||
const POST_LOGOUT_REDIRECT_URI =
|
||||
import.meta.env.VITE_LOGTO_POST_LOGOUT_REDIRECT_URI || window.location.origin;
|
||||
|
||||
const TOKEN_KEY = 'logto_token';
|
||||
const ID_TOKEN_KEY = 'logto_id_token';
|
||||
const REFRESH_KEY = 'logto_refresh';
|
||||
const USER_KEY = 'logto_user';
|
||||
|
||||
export interface LogtoUser {
|
||||
sub: string;
|
||||
id?: string; // alias pra sub (compat com Supabase)
|
||||
email?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
export interface LogtoTokens {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// === Storage helpers ===
|
||||
function saveTokens(t: LogtoTokens): void {
|
||||
localStorage.setItem(TOKEN_KEY, t.accessToken);
|
||||
localStorage.setItem(ID_TOKEN_KEY, t.idToken);
|
||||
if (t.refreshToken) localStorage.setItem(REFRESH_KEY, t.refreshToken);
|
||||
localStorage.setItem('logto_expires', String(t.expiresAt));
|
||||
}
|
||||
|
||||
function loadTokens(): LogtoTokens | null {
|
||||
const accessToken = localStorage.getItem(TOKEN_KEY);
|
||||
const idToken = localStorage.getItem(ID_TOKEN_KEY);
|
||||
const refreshToken = localStorage.getItem(REFRESH_KEY) || undefined;
|
||||
const expiresAt = Number(localStorage.getItem('logto_expires') || 0);
|
||||
if (!accessToken || !idToken) return null;
|
||||
return { accessToken, idToken, refreshToken, expiresAt };
|
||||
}
|
||||
|
||||
function clearTokens(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(ID_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem('logto_expires');
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
// === PKCE helpers ===
|
||||
function generateRandomString(len: number): string {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
const arr = new Uint8Array(len);
|
||||
crypto.getRandomValues(arr);
|
||||
let out = '';
|
||||
for (let i = 0; i < len; i++) out += charset[arr[i] % charset.length];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function sha256(input: string): Promise<ArrayBuffer> {
|
||||
const data = new TextEncoder().encode(input);
|
||||
return await crypto.subtle.digest('SHA-256', data);
|
||||
}
|
||||
|
||||
function base64UrlEncode(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let str = '';
|
||||
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
|
||||
const b64 = btoa(str);
|
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function generatePkce(): Promise<{ verifier: string; challenge: string }> {
|
||||
const verifier = generateRandomString(64);
|
||||
const challenge = base64UrlEncode(await sha256(verifier));
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
// === Auth flow ===
|
||||
export async function signIn(): Promise<void> {
|
||||
// Limpa estado anterior pra evitar PKCE mismatch
|
||||
try {
|
||||
sessionStorage.removeItem('logto_state');
|
||||
sessionStorage.removeItem('logto_nonce');
|
||||
sessionStorage.removeItem('logto_verifier');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const state = generateRandomString(32);
|
||||
const nonce = generateRandomString(32);
|
||||
const pkce = await generatePkce();
|
||||
|
||||
try {
|
||||
sessionStorage.setItem('logto_state', state);
|
||||
sessionStorage.setItem('logto_nonce', nonce);
|
||||
sessionStorage.setItem('logto_verifier', pkce.verifier);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: APP_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
window.location.assign(LOGTO_ENDPOINT + '/oidc/auth?' + params.toString());
|
||||
}
|
||||
|
||||
let callbackPromise: Promise<boolean> | null = null;
|
||||
|
||||
export async function handleCallback(): Promise<boolean> {
|
||||
if (callbackPromise) return callbackPromise;
|
||||
|
||||
callbackPromise = (async () => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
|
||||
console.log('[Logto Callback] Recebido:', { hasCode: !!code, hasState: !!state });
|
||||
|
||||
if (!code) return false;
|
||||
|
||||
const expectedState = sessionStorage.getItem('logto_state');
|
||||
const verifier = sessionStorage.getItem('logto_verifier') || '';
|
||||
|
||||
console.log('[Logto Callback] State match?', { received: state, expected: expectedState, hasVerifier: !!verifier });
|
||||
|
||||
if (state !== expectedState) {
|
||||
console.error('Logto: state mismatch');
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: APP_ID,
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
|
||||
const res = await fetch(LOGTO_ENDPOINT + '/oidc/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
console.error('Token exchange failed:', errorText);
|
||||
// Limpa código da URL pra evitar retry com code inválido
|
||||
try {
|
||||
sessionStorage.removeItem('logto_state');
|
||||
sessionStorage.removeItem('logto_nonce');
|
||||
sessionStorage.removeItem('logto_verifier');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
saveTokens({
|
||||
accessToken: data.access_token,
|
||||
idToken: data.id_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: Date.now() + (data.expires_in || 3600) * 1000,
|
||||
});
|
||||
|
||||
// Limpa params URL
|
||||
const cleanUrl = window.location.origin + window.location.pathname;
|
||||
window.history.replaceState({}, document.title, cleanUrl);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Callback error:', err);
|
||||
return false;
|
||||
} finally {
|
||||
// Limpa a promise depois de um tempo pra permitir novos logins futuros,
|
||||
// mas bloqueia execuções duplas imediatas (React Strict Mode / uso duplo)
|
||||
setTimeout(() => {
|
||||
callbackPromise = null;
|
||||
}, 2000);
|
||||
}
|
||||
})();
|
||||
return callbackPromise;
|
||||
}
|
||||
|
||||
export async function getUser(): Promise<LogtoUser | null> {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) return null;
|
||||
|
||||
const cached = localStorage.getItem(USER_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached) as LogtoUser;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(LOGTO_ENDPOINT + '/oidc/me', {
|
||||
headers: { Authorization: 'Bearer ' + tokens.accessToken },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) return getUser();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = (await res.json()) as LogtoUser;
|
||||
const result: LogtoUser = { ...user, id: user.sub };
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(result));
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens?.refreshToken) return false;
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: APP_ID,
|
||||
refresh_token: tokens.refreshToken,
|
||||
});
|
||||
|
||||
const res = await fetch(LOGTO_ENDPOINT + '/oidc/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) return false;
|
||||
|
||||
const data = await res.json();
|
||||
saveTokens({
|
||||
accessToken: data.access_token,
|
||||
idToken: data.id_token,
|
||||
refreshToken: data.refresh_token || tokens.refreshToken,
|
||||
expiresAt: Date.now() + (data.expires_in || 3600) * 1000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut(): Promise<void> {
|
||||
clearTokens();
|
||||
try {
|
||||
sessionStorage.removeItem('logto_state');
|
||||
sessionStorage.removeItem('logto_nonce');
|
||||
sessionStorage.removeItem('logto_verifier');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.location.assign(
|
||||
LOGTO_ENDPOINT +
|
||||
'/oidc/session/end?client_id=' +
|
||||
encodeURIComponent(APP_ID) +
|
||||
'&post_logout_redirect_uri=' +
|
||||
encodeURIComponent(POST_LOGOUT_REDIRECT_URI)
|
||||
);
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(_email: string): Promise<{ ok: boolean; error?: string }> {
|
||||
// Logto: usuário clica "Esqueci senha" na tela de login e informa email
|
||||
// Logto envia email com link de reset via SMTP já configurado
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return loadTokens() !== null;
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
+34
-330
@@ -16,57 +16,35 @@ import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
|
||||
|
||||
const Auth = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||
const [loginData, setLoginData] = useState({ email: '', password: '' });
|
||||
const [signupData, setSignupData] = useState({ email: '', password: '', confirmPassword: '' });
|
||||
const { signIn, signUp, user, loading, isRecoveryFlow, session } = useAuth();
|
||||
const { signIn, user, loading, isRecoveryFlow, session } = useAuth();
|
||||
const { brandSettings } = useBrandSettings();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
|
||||
const recoveryType = searchParams.get('type');
|
||||
const hashParams = new URLSearchParams(location.hash.substring(1));
|
||||
const hashType = hashParams.get('type');
|
||||
const accessToken = hashParams.get('access_token');
|
||||
const refreshToken = hashParams.get('refresh_token');
|
||||
|
||||
const shouldShowReset = Boolean(
|
||||
recoveryType === 'recovery' ||
|
||||
hashType === 'recovery' ||
|
||||
isRecoveryFlow ||
|
||||
(accessToken && refreshToken)
|
||||
);
|
||||
|
||||
// Estado para controlar se está em modo de recuperação
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(shouldShowReset);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('🔍 Auth.tsx - Verificando fluxo de recuperação...');
|
||||
console.log('Location:', {
|
||||
pathname: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash
|
||||
});
|
||||
console.log('Auth states:', {
|
||||
user: !!user,
|
||||
session: !!session,
|
||||
isRecoveryFlow,
|
||||
loading
|
||||
});
|
||||
|
||||
// Verificar se é fluxo de recuperação
|
||||
const recoveryType = searchParams.get('type');
|
||||
const hashParams = new URLSearchParams(location.hash.substring(1));
|
||||
const hashType = hashParams.get('type');
|
||||
const accessToken = hashParams.get('access_token');
|
||||
const refreshToken = hashParams.get('refresh_token');
|
||||
|
||||
console.log('🔍 Parâmetros de recuperação:', {
|
||||
recoveryType,
|
||||
hashType,
|
||||
accessToken: !!accessToken,
|
||||
refreshToken: !!refreshToken,
|
||||
isRecoveryFlow
|
||||
});
|
||||
|
||||
// Se há type=recovery OU isRecoveryFlow OU tokens, mostrar tela de reset
|
||||
const shouldShowReset = recoveryType === 'recovery' ||
|
||||
hashType === 'recovery' ||
|
||||
isRecoveryFlow ||
|
||||
(accessToken && refreshToken);
|
||||
|
||||
console.log('🔑 Deve mostrar reset de senha:', shouldShowReset);
|
||||
setShowPasswordReset(Boolean(shouldShowReset));
|
||||
}, [searchParams, location.hash, location.search, isRecoveryFlow, user, session]);
|
||||
if (shouldShowReset !== showPasswordReset) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
setShowPasswordReset(shouldShowReset);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shouldShowReset, showPasswordReset, loading, location.pathname]);
|
||||
|
||||
// Redirecionar usuários autenticados para página principal (exceto em fluxo de recuperação)
|
||||
useEffect(() => {
|
||||
@@ -139,112 +117,18 @@ const Auth = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const validatePassword = (password: string) => {
|
||||
const minLength = password.length >= 8;
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumbers = /\d/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
||||
|
||||
const score = [minLength, hasUpperCase, hasLowerCase, hasNumbers, hasSpecialChar].filter(Boolean).length;
|
||||
|
||||
return {
|
||||
score,
|
||||
minLength,
|
||||
hasUpperCase,
|
||||
hasLowerCase,
|
||||
hasNumbers,
|
||||
hasSpecialChar,
|
||||
isValid: score >= 4 && minLength
|
||||
};
|
||||
};
|
||||
|
||||
const passwordStrength = validatePassword(signupData.password);
|
||||
|
||||
// Email validation
|
||||
const isValidEmail = (email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!loginData.email || !loginData.password) {
|
||||
toast.error('Por favor, preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEmail(loginData.email)) {
|
||||
toast.error('Por favor, insira um email válido');
|
||||
return;
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
const { error } = await signIn(loginData.email, loginData.password);
|
||||
const { error } = await signIn();
|
||||
|
||||
if (error) {
|
||||
if (error.message.includes('Invalid login credentials')) {
|
||||
toast.error('Email ou senha incorretos');
|
||||
} else {
|
||||
toast.error('Erro ao fazer login: ' + error.message);
|
||||
}
|
||||
} else {
|
||||
toast.success('Login realizado com sucesso!');
|
||||
navigate('/');
|
||||
toast.error('Erro ao redirecionar para o login: ' + (error as Error).message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
// No else block needed as it redirects to external OIDC provider
|
||||
};
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!signupData.email || !signupData.password || !signupData.confirmPassword) {
|
||||
toast.error('Por favor, preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEmail(signupData.email)) {
|
||||
toast.error('Por favor, insira um email válido');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordStrength.isValid) {
|
||||
toast.error('A senha deve ter pelo menos 8 caracteres e incluir maiúsculas, minúsculas, números e símbolos');
|
||||
return;
|
||||
}
|
||||
|
||||
if (signupData.password !== signupData.confirmPassword) {
|
||||
toast.error('As senhas não coincidem');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { error } = await signUp(signupData.email, signupData.password);
|
||||
|
||||
if (error) {
|
||||
if (error.message.includes('User already registered')) {
|
||||
toast.error('Este email já está cadastrado');
|
||||
} else {
|
||||
toast.error('Erro ao criar conta: ' + error.message);
|
||||
}
|
||||
} else {
|
||||
toast.success('Conta criada com sucesso! Verifique seu email.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const getPasswordStrengthColor = (score: number) => {
|
||||
if (score <= 2) return 'bg-red-500';
|
||||
if (score <= 3) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
const getPasswordStrengthText = (score: number) => {
|
||||
if (score <= 2) return 'Fraca';
|
||||
if (score <= 3) return 'Média';
|
||||
return 'Forte';
|
||||
};
|
||||
|
||||
console.log('📱 Renderizando página de login/cadastro');
|
||||
return (
|
||||
@@ -281,188 +165,14 @@ const Auth = () => {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="login" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="login" className="text-sm">Entrar</TabsTrigger>
|
||||
<TabsTrigger value="signup" className="text-sm">Cadastrar</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="login" className="space-y-4">
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-email" className="text-sm font-medium">
|
||||
Email
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
value={loginData.email}
|
||||
onChange={(e) => setLoginData({ ...loginData, email: e.target.value })}
|
||||
className="pl-10 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-password" className="text-sm font-medium">
|
||||
Senha
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Sua senha"
|
||||
value={loginData.password}
|
||||
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
|
||||
className="pl-10 pr-10 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium text-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Entrando...' : 'Entrar'}
|
||||
</Button>
|
||||
|
||||
{/* Botão Esqueci a Senha */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotPassword(true)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Esqueci a senha
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="signup" className="space-y-4">
|
||||
<form onSubmit={handleSignup} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="signup-email" className="text-sm font-medium">
|
||||
Email
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="signup-email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
value={signupData.email}
|
||||
onChange={(e) => setSignupData({ ...signupData, email: e.target.value })}
|
||||
className="pl-10 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="signup-password" className="text-sm font-medium">
|
||||
Senha
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="signup-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
value={signupData.password}
|
||||
onChange={(e) => setSignupData({ ...signupData, password: e.target.value })}
|
||||
className="pl-10 pr-10 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{signupData.password && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getPasswordStrengthColor(passwordStrength.score)}`}
|
||||
style={{ width: `${(passwordStrength.score / 5) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getPasswordStrengthText(passwordStrength.score)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.minLength ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Mínimo 8 caracteres
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.hasUpperCase ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Letra maiúscula
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.hasNumbers ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Número
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.hasSpecialChar ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Símbolo especial
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="signup-confirm" className="text-sm font-medium">
|
||||
Confirmar Senha
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="signup-confirm"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Confirme sua senha"
|
||||
value={signupData.confirmPassword}
|
||||
onChange={(e) => setSignupData({ ...signupData, confirmPassword: e.target.value })}
|
||||
className="pl-10 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium text-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Criando conta...' : 'Criar conta'}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<CardContent className="pt-6">
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
className="w-full h-14 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium text-lg rounded-xl transition-all hover:scale-[1.02] shadow-md"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Redirecionando...' : 'Acessar Sistema Seguramente'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -471,12 +181,6 @@ const Auth = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Esqueci a Senha */}
|
||||
<ForgotPasswordModal
|
||||
isOpen={showForgotPassword}
|
||||
onClose={() => setShowForgotPassword(false)}
|
||||
/>
|
||||
</BeamsBackground>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -488,7 +488,7 @@ const CadastroOF = () => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="quantidade" className="text-sm font-medium text-muted-foreground">Quantidade (t)</Label>
|
||||
<Label htmlFor="quantidade" className="text-sm font-medium text-muted-foreground">Quantidade (kg)</Label>
|
||||
<Input
|
||||
id="quantidade"
|
||||
type="number"
|
||||
|
||||
@@ -240,6 +240,7 @@ export default function CadastroPecasFiltrado() {
|
||||
<CardContent>
|
||||
<PecaForm
|
||||
ofNumbers={[ofSelecionada]} // Apenas a OF selecionada
|
||||
ofDefault={ofSelecionada}
|
||||
onSave={handleSave}
|
||||
onUpdate={handleUpdate}
|
||||
onImportCSV={handleImportCSV}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { BeamsBackground } from '@/components/ui/beams-background';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
const Callback = () => {
|
||||
const navigate = useNavigate();
|
||||
const { handleCallback, user, loading } = useAuth();
|
||||
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing');
|
||||
const [message, setMessage] = useState('Processando login...');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function processCallback() {
|
||||
try {
|
||||
const ok = await handleCallback();
|
||||
if (cancelled) return;
|
||||
|
||||
if (ok) {
|
||||
setStatus('success');
|
||||
setMessage('Login realizado com sucesso!');
|
||||
setTimeout(() => navigate('/'), 1000);
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage('Falha no login. Tente novamente.');
|
||||
// Não navega automaticamente - deixa o user ver o erro
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setStatus('error');
|
||||
setMessage('Erro inesperado: ' + (err as Error).message);
|
||||
setTimeout(() => navigate('/auth'), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
processCallback();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handleCallback, navigate]);
|
||||
|
||||
// Se já tá logado, redireciona
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [user, loading, navigate]);
|
||||
|
||||
return (
|
||||
<BeamsBackground intensity="medium">
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="text-center bg-background/80 backdrop-blur-sm rounded-2xl shadow-xl p-8 max-w-md">
|
||||
{status === 'processing' && (
|
||||
<>
|
||||
<div className="h-12 w-12 mx-auto mb-4 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-lg font-medium text-foreground">{message}</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-medium text-foreground">{message}</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-red-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-medium text-foreground">{message}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BeamsBackground>
|
||||
);
|
||||
};
|
||||
|
||||
export default Callback;
|
||||
@@ -174,22 +174,7 @@ const ConversoresDados = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (showAdvanceSteelConverter) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGoBack}
|
||||
className="bg-slate-600 border-slate-500 text-white hover:bg-slate-500"
|
||||
>
|
||||
← Voltar
|
||||
</Button>
|
||||
</div>
|
||||
<AdvanceSteelConverter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -373,6 +358,12 @@ const ConversoresDados = () => {
|
||||
open={showGenericConverter}
|
||||
onOpenChange={setShowGenericConverter}
|
||||
/>
|
||||
|
||||
{/* Modal de Conversão Advance Steel (PDF para Excel) */}
|
||||
<AdvanceSteelConverter
|
||||
open={showAdvanceSteelConverter}
|
||||
onOpenChange={setShowAdvanceSteelConverter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import { CronogramaGantt } from '@/components/cronograma/CronogramaGantt';
|
||||
import { CronogramaPDF } from '@/components/cronograma/CronogramaPDF';
|
||||
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
|
||||
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
||||
import { CronogramaOf } from '@/types/cronograma';
|
||||
import { CronogramaOf, ProcessoCronograma } from '@/types/cronograma';
|
||||
|
||||
const CronogramaOF = () => {
|
||||
const { cronogramas, loading, loadCronogramas, deleteCronograma } = useCronogramas();
|
||||
@@ -24,7 +24,7 @@ const CronogramaOF = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedOF, setSelectedOF] = useState<string>('');
|
||||
const [showCronogramaForm, setShowCronogramaForm] = useState(false);
|
||||
const [selectedCronograma, setSelectedCronograma] = useState<any>(null);
|
||||
const [selectedCronograma, setSelectedCronograma] = useState<CronogramaOf | null>(null);
|
||||
const [showGanttChart, setShowGanttChart] = useState(false);
|
||||
const [showPDFGenerator, setShowPDFGenerator] = useState(false);
|
||||
const [cronogramaForGantt, setCronogramaForGantt] = useState<CronogramaOf | null>(null);
|
||||
@@ -42,7 +42,7 @@ const CronogramaOF = () => {
|
||||
return ofA.localeCompare(ofB);
|
||||
});
|
||||
|
||||
const handleEdit = (cronograma: any) => {
|
||||
const handleEdit = (cronograma: CronogramaOf) => {
|
||||
if (!canEdit()) return;
|
||||
setSelectedCronograma(cronograma);
|
||||
setShowCronogramaForm(true);
|
||||
@@ -68,6 +68,11 @@ const CronogramaOF = () => {
|
||||
setSelectedCronograma(null);
|
||||
};
|
||||
|
||||
const handleSaveSuccess = () => {
|
||||
loadCronogramas();
|
||||
setActiveTab('cronogramas');
|
||||
};
|
||||
|
||||
const handleCloseGanttChart = () => {
|
||||
setShowGanttChart(false);
|
||||
setCronogramaForGantt(null);
|
||||
@@ -78,7 +83,7 @@ const CronogramaOF = () => {
|
||||
setCronogramaForPDF(null);
|
||||
};
|
||||
|
||||
const MobileCronogramaCard = ({ cronograma }: { cronograma: any }) => (
|
||||
const MobileCronogramaCard = ({ cronograma }: { cronograma: CronogramaOf }) => (
|
||||
<Card className="w-full mb-4 bg-card border-border">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
@@ -120,7 +125,7 @@ const CronogramaOF = () => {
|
||||
<div className="mt-1 text-card-foreground">
|
||||
{Array.isArray(cronograma.processos) && cronograma.processos.length > 0 ? (
|
||||
<div className="text-xs">
|
||||
{cronograma.processos.map((processo: any, index: number) => (
|
||||
{cronograma.processos.map((processo: ProcessoCronograma, index: number) => (
|
||||
<div key={index} className="py-1 border-b border-border last:border-0">
|
||||
{processo.nome_processo || `Processo ${index + 1}`}
|
||||
</div>
|
||||
@@ -285,6 +290,7 @@ const CronogramaOF = () => {
|
||||
cronograma={selectedCronograma}
|
||||
onClose={handleCloseCronogramaForm}
|
||||
isOpen={showCronogramaForm}
|
||||
onSaveSuccess={handleSaveSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -18,6 +18,55 @@ import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { UserResourcePermissions } from '@/components/mapa-interativo/UserResourcePermissions';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const createCurvedPath = (fromX: number, fromY: number, toX: number, toY: number) => {
|
||||
const midX = (fromX + toX) / 2;
|
||||
const midY = (fromY + toY) / 2;
|
||||
|
||||
const dx = toX - fromX;
|
||||
const dy = toY - fromY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
const curvature = Math.min(distance * 0.3, 100);
|
||||
const offsetX = -dy / distance * curvature;
|
||||
const offsetY = dx / distance * curvature;
|
||||
|
||||
const controlX = midX + offsetX;
|
||||
const controlY = midY + offsetY;
|
||||
|
||||
return `M ${fromX} ${fromY} Q ${controlX} ${controlY} ${toX} ${toY}`;
|
||||
};
|
||||
|
||||
const getArrowPositions = (fromX: number, fromY: number, toX: number, toY: number) => {
|
||||
const arrows = [];
|
||||
const steps = 3; // Número de setas ao longo da linha
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / (steps + 1);
|
||||
|
||||
const midX = (fromX + toX) / 2;
|
||||
const midY = (fromY + toY) / 2;
|
||||
const dx = toX - fromX;
|
||||
const dy = toY - fromY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const curvature = Math.min(distance * 0.3, 100);
|
||||
const offsetX = -dy / distance * curvature;
|
||||
const offsetY = dx / distance * curvature;
|
||||
const controlX = midX + offsetX;
|
||||
const controlY = midY + offsetY;
|
||||
|
||||
const x = (1 - t) * (1 - t) * fromX + 2 * (1 - t) * t * controlX + t * t * toX;
|
||||
const y = (1 - t) * (1 - t) * fromY + 2 * (1 - t) * t * controlY + t * t * toY;
|
||||
|
||||
const tangentX = 2 * (1 - t) * (controlX - fromX) + 2 * t * (toX - controlX);
|
||||
const tangentY = 2 * (1 - t) * (controlY - fromY) + 2 * t * (toY - controlY);
|
||||
const angle = Math.atan2(tangentY, tangentX) * 180 / Math.PI;
|
||||
|
||||
arrows.push({ x, y, angle });
|
||||
}
|
||||
|
||||
return arrows;
|
||||
};
|
||||
|
||||
export default function MapaInterativo() {
|
||||
const { nodes, connections } = useSystemMapAutoDiscovery();
|
||||
const { isAdmin } = useUserRole();
|
||||
@@ -36,21 +85,25 @@ export default function MapaInterativo() {
|
||||
{ id: 'permissions', label: 'Meus Acessos', icon: Shield }
|
||||
];
|
||||
|
||||
const filteredNodes = nodes.filter(node => {
|
||||
switch (activeFilter) {
|
||||
case 'main-flow': return node.isMainFlow;
|
||||
case 'permissions': return node.hasAccess;
|
||||
default: return true;
|
||||
}
|
||||
});
|
||||
const filteredNodes = useMemo(() => {
|
||||
return nodes.filter(node => {
|
||||
switch (activeFilter) {
|
||||
case 'main-flow': return node.isMainFlow;
|
||||
case 'permissions': return node.hasAccess;
|
||||
default: return true;
|
||||
}
|
||||
});
|
||||
}, [nodes, activeFilter]);
|
||||
|
||||
const filteredConnections = connections.filter(conn => {
|
||||
const sourceExists = filteredNodes.some(n => n.id === conn.from);
|
||||
const targetExists = filteredNodes.some(n => n.id === conn.to);
|
||||
return sourceExists && targetExists;
|
||||
});
|
||||
const filteredConnections = useMemo(() => {
|
||||
return connections.filter(conn => {
|
||||
const sourceExists = filteredNodes.some(n => n.id === conn.from);
|
||||
const targetExists = filteredNodes.some(n => n.id === conn.to);
|
||||
return sourceExists && targetExists;
|
||||
});
|
||||
}, [connections, filteredNodes]);
|
||||
|
||||
const getMapDimensions = () => {
|
||||
const getMapDimensions = useCallback(() => {
|
||||
if (filteredNodes.length === 0) return { width: 1200, height: 800 };
|
||||
|
||||
const cardWidth = 240;
|
||||
@@ -78,71 +131,30 @@ export default function MapaInterativo() {
|
||||
width: Math.max(1200, maxX + padding),
|
||||
height: Math.max(800, maxY + padding)
|
||||
};
|
||||
};
|
||||
}, [filteredNodes]);
|
||||
|
||||
const [mapDimensions, setMapDimensions] = useState(getMapDimensions());
|
||||
const [mapDimensions, setMapDimensions] = useState({ width: 1200, height: 800 });
|
||||
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
setMapDimensions(getMapDimensions());
|
||||
setMapDimensions(prev => {
|
||||
const next = getMapDimensions();
|
||||
if (prev.width === next.width && prev.height === next.height) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
updateDimensions();
|
||||
const interval = setInterval(updateDimensions, 1000); // Atualizar periodicamente
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [filteredNodes]);
|
||||
}, [getMapDimensions]);
|
||||
|
||||
const createCurvedPath = (fromX: number, fromY: number, toX: number, toY: number) => {
|
||||
const midX = (fromX + toX) / 2;
|
||||
const midY = (fromY + toY) / 2;
|
||||
|
||||
const dx = toX - fromX;
|
||||
const dy = toY - fromY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
const curvature = Math.min(distance * 0.3, 100);
|
||||
const offsetX = -dy / distance * curvature;
|
||||
const offsetY = dx / distance * curvature;
|
||||
|
||||
const controlX = midX + offsetX;
|
||||
const controlY = midY + offsetY;
|
||||
|
||||
return `M ${fromX} ${fromY} Q ${controlX} ${controlY} ${toX} ${toY}`;
|
||||
};
|
||||
|
||||
const getArrowPositions = (fromX: number, fromY: number, toX: number, toY: number) => {
|
||||
const arrows = [];
|
||||
const steps = 3; // Número de setas ao longo da linha
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / (steps + 1);
|
||||
|
||||
const midX = (fromX + toX) / 2;
|
||||
const midY = (fromY + toY) / 2;
|
||||
const dx = toX - fromX;
|
||||
const dy = toY - fromY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const curvature = Math.min(distance * 0.3, 100);
|
||||
const offsetX = -dy / distance * curvature;
|
||||
const offsetY = dx / distance * curvature;
|
||||
const controlX = midX + offsetX;
|
||||
const controlY = midY + offsetY;
|
||||
|
||||
const x = (1 - t) * (1 - t) * fromX + 2 * (1 - t) * t * controlX + t * t * toX;
|
||||
const y = (1 - t) * (1 - t) * fromY + 2 * (1 - t) * t * controlY + t * t * toY;
|
||||
|
||||
const tangentX = 2 * (1 - t) * (controlX - fromX) + 2 * t * (toX - controlX);
|
||||
const tangentY = 2 * (1 - t) * (controlY - fromY) + 2 * t * (toY - controlY);
|
||||
const angle = Math.atan2(tangentY, tangentX) * 180 / Math.PI;
|
||||
|
||||
arrows.push({ x, y, angle });
|
||||
}
|
||||
|
||||
return arrows;
|
||||
};
|
||||
|
||||
const updateConnections = () => {
|
||||
const updateConnections = useCallback(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
|
||||
const container = mapContainerRef.current;
|
||||
@@ -179,14 +191,14 @@ export default function MapaInterativo() {
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [filteredConnections]);
|
||||
|
||||
useEffect(() => {
|
||||
updateConnections();
|
||||
const handleResize = () => updateConnections();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [filteredNodes, filteredConnections]);
|
||||
}, [updateConnections]);
|
||||
|
||||
const handleNodeMouseOver = (nodeId: string) => {
|
||||
const relatedNodes = new Set<string>();
|
||||
|
||||
+112
-235
@@ -24,7 +24,8 @@ const PrioridadesFabricacao = () => {
|
||||
ofSelecionada,
|
||||
faseSelecionada,
|
||||
versaoAtual,
|
||||
onFiltroChange
|
||||
onFiltroChange,
|
||||
incrementarRevisao
|
||||
} = useItensPrioridadeFabricacaoFiltrado();
|
||||
|
||||
const [showPecaSelector, setShowPecaSelector] = useState(false);
|
||||
@@ -58,17 +59,30 @@ const PrioridadesFabricacao = () => {
|
||||
const numBigBoxes = Math.floor(quantity / 5);
|
||||
const numSmallBoxes = quantity % 5;
|
||||
for (let i = 0; i < numBigBoxes; i++) {
|
||||
boxesHtml += `<div class="tick-box-large"><span>5</span></div>`;
|
||||
boxesHtml += `
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -1px; margin-right: 2px;">
|
||||
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#f3f4f6" stroke="#4b5563" stroke-width="1" />
|
||||
<text x="6.5" y="9.5" text-anchor="middle" font-size="8.5" font-family="Arial, sans-serif" font-weight="bold" fill="#4b5563">5</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
for (let i = 0; i < numSmallBoxes; i++) {
|
||||
boxesHtml += `<div class="tick-box"></div>`;
|
||||
boxesHtml += `
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -1px; margin-right: 2px;">
|
||||
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
boxesHtml += `<div class="tick-box"></div>`;
|
||||
boxesHtml += `
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -1px; margin-right: 2px;">
|
||||
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
}
|
||||
return `<div class="flex items-center flex-wrap gap-1">${boxesHtml}</div>`;
|
||||
return `<span style="display: inline-block; vertical-align: middle; margin-left: 4px;">${boxesHtml}</span>`;
|
||||
};
|
||||
|
||||
const handleImprimirRelatorio = async () => {
|
||||
@@ -77,6 +91,14 @@ const PrioridadesFabricacao = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
let revisaoParaImprimir = versaoAtual?.revisao || 0;
|
||||
if (window.confirm(`Deseja gerar uma nova revisão (Rev. ${(versaoAtual?.revisao || 0) + 1}) para esta impressão?`)) {
|
||||
const sucesso = await incrementarRevisao();
|
||||
if (sucesso) {
|
||||
revisaoParaImprimir += 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Criar nova janela para impressão
|
||||
const printWindow = window.open('', '_blank');
|
||||
@@ -106,11 +128,11 @@ const PrioridadesFabricacao = () => {
|
||||
|
||||
const getCoresPrioridade = (codigo: string) => {
|
||||
switch (codigo) {
|
||||
case 'P1': return 'text-red-700 bg-red-100';
|
||||
case 'P2': return 'text-orange-700 bg-orange-100';
|
||||
case 'P3': return 'text-blue-700 bg-blue-100';
|
||||
case 'P4': return 'text-gray-700 bg-gray-200';
|
||||
default: return 'text-gray-700 bg-gray-200';
|
||||
case 'P1': return 'text-red-700 bg-red-100 border-red-300';
|
||||
case 'P2': return 'text-orange-700 bg-orange-100 border-orange-300';
|
||||
case 'P3': return 'text-blue-700 bg-blue-100 border-blue-300';
|
||||
case 'P4': return 'text-gray-700 bg-gray-200 border-gray-300';
|
||||
default: return 'text-gray-700 bg-gray-200 border-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -119,15 +141,15 @@ const PrioridadesFabricacao = () => {
|
||||
|
||||
itemsContent += `
|
||||
<div class="priority-group ${pageBreakClass}">
|
||||
<h2 class="text-lg font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-3">
|
||||
<h2 class="text-base font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-2.5 border">
|
||||
${getPrioridadeNome(codigo)}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
<div class="space-y-1.5">
|
||||
`;
|
||||
|
||||
// Gerar linhas de itens (3 por linha)
|
||||
for (let i = 0; i < Math.ceil(itens.length / 3); i++) {
|
||||
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50' : 'bg-white';
|
||||
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50/70' : 'bg-white';
|
||||
const rowItems = itens.slice(i * 3, (i + 1) * 3);
|
||||
|
||||
itemsContent += `<div class="grid grid-cols-3 gap-2 p-1 rounded-md ${bgColorClass}">`;
|
||||
@@ -141,13 +163,13 @@ const PrioridadesFabricacao = () => {
|
||||
|
||||
itemsContent += `
|
||||
<div class="item-card">
|
||||
<div class="flex items-center flex-wrap gap-2 mb-2">
|
||||
<span class="font-semibold text-sm whitespace-nowrap">${marca} (${quantidade})</span>
|
||||
<span class="text-xs font-medium text-gray-500">${infoType}</span>
|
||||
<div style="margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
<span style="font-size: 13px; font-weight: 700; color: #111827; vertical-align: middle; margin-right: 4px;">${marca} (${quantidade})</span>
|
||||
<span style="font-size: 11px; font-weight: 600; color: #6b7280; vertical-align: middle;">${infoType}</span>
|
||||
${tickBoxes}
|
||||
</div>
|
||||
<div class="mt-2 text-xs">
|
||||
<div class="border-b border-gray-400 pb-1 h-5">Data/Operador:</div>
|
||||
<div class="item-signature">
|
||||
Data/Operador:
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -176,20 +198,33 @@ const PrioridadesFabricacao = () => {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Checklist de Produção por Prioridade</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.item-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.item-signature {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: #4b5563;
|
||||
border-bottom: 1px solid #9ca3af;
|
||||
padding-bottom: 2px;
|
||||
height: 18px;
|
||||
line-height: 14px;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
font-size: 9px;
|
||||
}
|
||||
.check-box-print {
|
||||
border: 1px solid #333 !important;
|
||||
}
|
||||
.page-break {
|
||||
page-break-before: always;
|
||||
}
|
||||
@@ -200,85 +235,80 @@ const PrioridadesFabricacao = () => {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
.item-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.tick-box {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1px solid #6b7280;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tick-box-large {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #6b7280;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tick-box-large span {
|
||||
color: #d1d5db;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-white">
|
||||
<div class="max-w-4xl mx-auto p-6 sm:p-8">
|
||||
<!-- Cabeçalho do Relatório -->
|
||||
<div class="flex justify-between items-center border-b-2 border-gray-800 pb-4 mb-4">
|
||||
<div class="flex justify-between items-center border-b-2 border-gray-800 pb-3 mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Checklist de Produção</h1>
|
||||
<p class="text-gray-600">Formulário para apontamento da fabricação.</p>
|
||||
<h1 class="text-2xl font-bold text-gray-900 leading-tight">Checklist de Produção</h1>
|
||||
<p class="text-xs text-gray-600">Formulário para apontamento da fabricação.</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">Data de Emissão: <span class="font-normal">${dataAtual}</span>
|
||||
${versaoAtual ? `<span class="ml-2 text-gray-500">Rev. ${versaoAtual.revisao}</span>` : ''}
|
||||
<p class="font-semibold text-sm">Data de Emissão: <span class="font-normal">${dataAtual}</span>
|
||||
<span class="ml-2 text-gray-500 font-medium">Rev. ${revisaoParaImprimir}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informações da OF e Fase (Layout Melhorado) -->
|
||||
<div class="border border-gray-200 bg-white p-4 rounded-lg mb-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||
<!-- Informações da OF e Fase -->
|
||||
<div class="border border-gray-200 bg-white p-3.5 rounded-lg mb-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-3">
|
||||
<!-- Coluna OF -->
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
|
||||
<p class="text-base font-bold text-gray-800">${ofSelecionada}</p>
|
||||
<p class="text-base font-bold text-gray-800 leading-snug">${ofSelecionada}</p>
|
||||
</div>
|
||||
<!-- Coluna Fase -->
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500">Fase</p>
|
||||
<p class="text-base font-bold text-gray-800">${faseSelecionada}</p>
|
||||
<p class="text-base font-bold text-gray-800 leading-snug">${faseSelecionada}</p>
|
||||
</div>
|
||||
<!-- Coluna Processo -->
|
||||
<div class="md:col-span-2">
|
||||
<p class="text-xs font-medium text-gray-500">PROCESSO</p>
|
||||
<div class="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1">
|
||||
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Corte</span></div>
|
||||
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Solda</span></div>
|
||||
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Pintura</span></div>
|
||||
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Expedição</span></div>
|
||||
<p class="text-xs font-medium text-gray-500 mb-1">PROCESSO</p>
|
||||
<div style="margin-top: 2px;">
|
||||
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
|
||||
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Corte</span>
|
||||
</div>
|
||||
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
|
||||
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Solda</span>
|
||||
</div>
|
||||
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
|
||||
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Pintura</span>
|
||||
</div>
|
||||
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
|
||||
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Expedição</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legenda -->
|
||||
<div class="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3">
|
||||
<span class="font-semibold">Legenda:</span>
|
||||
<span>Marca (Qtd)</span>
|
||||
<span class="font-medium text-gray-500">(S/M)</span>
|
||||
<span>= Sem Montagem,</span>
|
||||
<span class="font-medium text-gray-500">(C/M)</span>
|
||||
<span>= Com Montagem. Os quadrados</span>
|
||||
<div class="tick-box inline-block"></div>
|
||||
<div class="text-xs text-gray-600 mb-5 flex items-center flex-wrap">
|
||||
<span class="font-semibold" style="margin-right: 6px;">Legenda:</span>
|
||||
<span style="margin-right: 6px;">Marca (Qtd)</span>
|
||||
<span class="font-medium text-gray-500" style="margin-right: 4px;">(S/M)</span>
|
||||
<span style="margin-right: 6px;">= Sem Montagem,</span>
|
||||
<span class="font-medium text-gray-500" style="margin-right: 4px;">(C/M)</span>
|
||||
<span style="margin-right: 4px;">= Com Montagem. Os quadrados</span>
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -2px; margin: 0 4px;">
|
||||
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1" />
|
||||
</svg>
|
||||
<span>indicam o controle de peças fabricadas.</span>
|
||||
</div>
|
||||
|
||||
@@ -369,7 +399,12 @@ const PrioridadesFabricacao = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPrioridadesPDF(true)}
|
||||
onClick={async () => {
|
||||
if (window.confirm(`Deseja gerar uma nova revisão (Rev. ${(versaoAtual?.revisao || 0) + 1}) para este PDF?`)) {
|
||||
await incrementarRevisao();
|
||||
}
|
||||
setShowPrioridadesPDF(true);
|
||||
}}
|
||||
disabled={!ofSelecionada || !faseSelecionada}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
@@ -420,168 +455,10 @@ const PrioridadesFabricacao = () => {
|
||||
isOpen={showPrioridadesPDF}
|
||||
onClose={() => setShowPrioridadesPDF(false)}
|
||||
itensPorPrioridade={itensPorPrioridade}
|
||||
ofSelecionada={ofSelecionada}
|
||||
faseSelecionada={faseSelecionada}
|
||||
versaoAtual={versaoAtual}
|
||||
/>
|
||||
|
||||
{/* Componente oculto para impressão */}
|
||||
<div className="hidden">
|
||||
<div id="prioridades-pdf-content">
|
||||
{(ofSelecionada && faseSelecionada) && (
|
||||
<div className="bg-white text-black max-w-4xl mx-auto p-6">
|
||||
{/* Usar o mesmo template do PDF */}
|
||||
<div className="flex justify-between items-center border-b-2 border-gray-800 pb-4 mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Checklist de Produção</h1>
|
||||
<p className="text-gray-600">Formulário para apontamento da fabricação.</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">
|
||||
Data de Emissão: <span className="font-normal">{new Date().toLocaleDateString('pt-BR')}</span>
|
||||
{versaoAtual && (
|
||||
<span className="ml-2 text-gray-500">Rev. {versaoAtual.revisao}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 bg-white p-4 rounded-lg mb-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
|
||||
<p className="text-base font-bold text-gray-800">{ofSelecionada}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500">Fase</p>
|
||||
<p className="text-base font-bold text-gray-800">{faseSelecionada}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs font-medium text-gray-500">PROCESSO</p>
|
||||
<div className="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1">
|
||||
{['Corte', 'Solda', 'Pintura', 'Expedição'].map((processo) => (
|
||||
<div key={processo} className="flex items-center gap-1">
|
||||
<div className="w-4 h-4 border-2 border-gray-500"></div>
|
||||
<span className="text-sm font-semibold text-gray-700">{processo}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3">
|
||||
<span className="font-semibold">Legenda:</span>
|
||||
<span>Marca (Qtd)</span>
|
||||
<span className="font-medium text-gray-500">(S/M)</span>
|
||||
<span>= Sem Montagem,</span>
|
||||
<span className="font-medium text-gray-500">(C/M)</span>
|
||||
<span>= Com Montagem. Os quadrados indicam o controle de peças fabricadas.</span>
|
||||
</div>
|
||||
|
||||
{/* Renderizar os itens por prioridade */}
|
||||
<div className="space-y-8">
|
||||
{['P1', 'P2', 'P3', 'P4'].map((codigo, priorityIndex) => {
|
||||
const itens = itensPorPrioridade[codigo] || [];
|
||||
if (itens.length === 0) return null;
|
||||
|
||||
const getPrioridadeNome = (codigo: string) => {
|
||||
switch (codigo) {
|
||||
case 'P1': return 'Prioridade P1 - Urgente';
|
||||
case 'P2': return 'Prioridade P2 - Alta';
|
||||
case 'P3': return 'Prioridade P3 - Média';
|
||||
case 'P4': return 'Prioridade P4 - Baixa';
|
||||
default: return 'Desconhecida';
|
||||
}
|
||||
};
|
||||
|
||||
const getCoresPrioridade = (codigo: string) => {
|
||||
switch (codigo) {
|
||||
case 'P1': return 'text-red-700 bg-red-100';
|
||||
case 'P2': return 'text-orange-700 bg-orange-100';
|
||||
case 'P3': return 'text-blue-700 bg-blue-100';
|
||||
case 'P4': return 'text-gray-700 bg-gray-200';
|
||||
default: return 'text-gray-700 bg-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={codigo}>
|
||||
<h2 className={`text-lg font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-3`}>
|
||||
{getPrioridadeNome(codigo)}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: Math.ceil(itens.length / 3) }, (_, i) => {
|
||||
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50' : 'bg-white';
|
||||
const rowItems = itens.slice(i * 3, (i + 1) * 3);
|
||||
|
||||
return (
|
||||
<div key={i} className={`grid grid-cols-3 gap-2 p-1 rounded-md ${bgColorClass}`}>
|
||||
{rowItems.map((item) => {
|
||||
const quantidade = item.quantidade_priorizada;
|
||||
const marca = item.peca?.marca || 'N/A';
|
||||
const temComponentes = item.peca?.tem_componentes;
|
||||
const infoType = temComponentes ? '(C/M)' : '(S/M)';
|
||||
|
||||
// Gerar checkboxes
|
||||
const generateTickBoxes = (quantity: number) => {
|
||||
const boxes = [];
|
||||
|
||||
if (quantity > 10) {
|
||||
const numBigBoxes = Math.floor(quantity / 5);
|
||||
const numSmallBoxes = quantity % 5;
|
||||
|
||||
for (let i = 0; i < numBigBoxes; i++) {
|
||||
boxes.push(
|
||||
<div key={`big-${i}`} className="tick-box-large">
|
||||
<span>5</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < numSmallBoxes; i++) {
|
||||
boxes.push(<div key={`small-${i}`} className="tick-box"></div>);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
boxes.push(<div key={i} className="tick-box"></div>);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="flex items-center flex-wrap gap-1">{boxes}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={item.id} className="item-card">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||
<span className="font-semibold text-sm whitespace-nowrap">
|
||||
{marca} ({quantidade})
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-500">{infoType}</span>
|
||||
{generateTickBoxes(quantidade)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs">
|
||||
<div className="border-b border-gray-400 pb-1 h-5">Data/Operador:</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Preencher células vazias se necessário */}
|
||||
{Array.from({ length: 3 - rowItems.length }, (_, emptyIndex) => (
|
||||
<div key={`empty-${emptyIndex}`}></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StandardPageLayout>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StandardCard } from '@/components/layout/StandardCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Printer, Edit, Trash2, Eye, RefreshCw, Check, ShoppingCart } from 'lucide-react';
|
||||
import { useSolicitacoesCompra } from '@/hooks/useSolicitacoesCompra';
|
||||
import { useSolicitacoesCompra, SolicitacaoCompra, ItemSolicitacao } from '@/hooks/useSolicitacoesCompra';
|
||||
import { useUserFunction } from '@/hooks/useUserFunction';
|
||||
import { SolicitacaoComprasModal } from '@/components/solicitacao-compras/SolicitacaoComprasModal';
|
||||
import { SolicitacaoComprasPreviewModal } from '@/components/solicitacao-compras/SolicitacaoComprasPreviewModal';
|
||||
@@ -25,7 +25,7 @@ const statusColors = {
|
||||
export default function SolicitacaoCompras() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [selectedSolicitacao, setSelectedSolicitacao] = useState(null);
|
||||
const [selectedSolicitacao, setSelectedSolicitacao] = useState<SolicitacaoCompra | null>(null);
|
||||
const { isComprador } = useUserFunction();
|
||||
const {
|
||||
solicitacoes,
|
||||
@@ -44,7 +44,7 @@ export default function SolicitacaoCompras() {
|
||||
isComprandoDireto
|
||||
} = useSolicitacoesCompra();
|
||||
|
||||
const handlePrint = (solicitacao: any) => {
|
||||
const handlePrint = (solicitacao: SolicitacaoCompra) => {
|
||||
try {
|
||||
const printContent = `
|
||||
<!DOCTYPE html>
|
||||
@@ -129,7 +129,7 @@ export default function SolicitacaoCompras() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
${solicitacao.itens.map((item: any, index: number) => `
|
||||
${solicitacao.itens.map((item: ItemSolicitacao, index: number) => `
|
||||
<tr${index % 2 === 1 ? ' class="hover:bg-gray-50"' : ''}>
|
||||
<td class="px-4 py-3 text-xs font-medium text-gray-800 break-words">${item.material?.descricao || 'Material não encontrado'}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center text-gray-600 text-xs">${item.material?.unidade || 'UN'}</td>
|
||||
@@ -180,7 +180,7 @@ export default function SolicitacaoCompras() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (solicitacao: any) => {
|
||||
const handleEdit = (solicitacao: SolicitacaoCompra) => {
|
||||
if (!canEdit(solicitacao)) {
|
||||
alert('Você só pode editar suas próprias solicitações.');
|
||||
return;
|
||||
@@ -189,12 +189,12 @@ export default function SolicitacaoCompras() {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePreview = (solicitacao: any) => {
|
||||
const handlePreview = (solicitacao: SolicitacaoCompra) => {
|
||||
setSelectedSolicitacao(solicitacao);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (solicitacao: any) => {
|
||||
const handleDelete = (solicitacao: SolicitacaoCompra) => {
|
||||
if (!canDelete(solicitacao)) {
|
||||
if (solicitacao.status !== 'Em planejamento') {
|
||||
alert('⚠️ PERMISSÃO NEGADA\n\nSolicitações com status diferente de "Em planejamento" só podem ser excluídas por compradores.');
|
||||
@@ -209,25 +209,25 @@ export default function SolicitacaoCompras() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevisar = (solicitacao: any) => {
|
||||
const handleRevisar = (solicitacao: SolicitacaoCompra) => {
|
||||
if (window.confirm(`Tem certeza que deseja enviar a solicitação ${solicitacao.numero_sc} para revisão?`)) {
|
||||
revisar({ id: solicitacao.id, created_by: solicitacao.created_by });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAceitar = (solicitacao: any) => {
|
||||
const handleAceitar = (solicitacao: SolicitacaoCompra) => {
|
||||
if (window.confirm(`Tem certeza que deseja aceitar a solicitação ${solicitacao.numero_sc}?`)) {
|
||||
aceitar({ id: solicitacao.id, created_by: solicitacao.created_by });
|
||||
}
|
||||
};
|
||||
|
||||
const handleComprar = (solicitacao: any) => {
|
||||
const handleComprar = (solicitacao: SolicitacaoCompra) => {
|
||||
if (window.confirm(`Tem certeza que deseja marcar a solicitação ${solicitacao.numero_sc} como comprada?`)) {
|
||||
comprar({ id: solicitacao.id, created_by: solicitacao.created_by });
|
||||
}
|
||||
};
|
||||
|
||||
const handleComprarDireto = (solicitacao: any) => {
|
||||
const handleComprarDireto = (solicitacao: SolicitacaoCompra) => {
|
||||
if (window.confirm(`Você deseja passar o status dessa SC ${solicitacao.numero_sc} para "Comprado"?`)) {
|
||||
comprarDireto({ id: solicitacao.id, created_by: solicitacao.created_by });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Search, Eye, Download, FileText, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||
import { useOFsAtivas } from '@/hooks/useOFsAtivas';
|
||||
import { useAuditoriaInconsistencias } from '@/hooks/useAuditoriaInconsistencias';
|
||||
import { useAuditoriaInconsistencias, InconsistenciaItem } from '@/hooks/useAuditoriaInconsistencias';
|
||||
import { InconsistenciaDetalhesModal } from '@/components/auditoria/InconsistenciaDetalhesModal';
|
||||
import { generateProfessionalPDF } from '@/utils/pdfGenerator';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
@@ -18,7 +18,7 @@ const VerInconsistencias = () => {
|
||||
const [selectedFase, setSelectedFase] = useState<string>('');
|
||||
const [selectedProcesso, setSelectedProcesso] = useState<string>('todas');
|
||||
const [fasesDaOF, setFasesDaOF] = useState<string[]>([]);
|
||||
const [detalhesModal, setDetalhesModal] = useState<{ open: boolean; inconsistencia: any }>({
|
||||
const [detalhesModal, setDetalhesModal] = useState<{ open: boolean; inconsistencia: InconsistenciaItem | null }>({
|
||||
open: false,
|
||||
inconsistencia: null
|
||||
});
|
||||
@@ -113,7 +113,7 @@ const VerInconsistencias = () => {
|
||||
|
||||
const getTotalInconsistencias = () => {
|
||||
if (!resultadoAuditoria) return 0;
|
||||
return Object.values(resultadoAuditoria.inconsistencias).reduce((total, categoria: any) => total + categoria.length, 0);
|
||||
return Object.values(resultadoAuditoria.inconsistencias).reduce((total, categoria: InconsistenciaItem[]) => total + categoria.length, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -262,7 +262,7 @@ const VerInconsistencias = () => {
|
||||
<CardTitle className="capitalize">
|
||||
Inconsistências de {categoria}
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{(inconsistencias as any[]).length}
|
||||
{(inconsistencias as InconsistenciaItem[]).length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -274,7 +274,7 @@ const VerInconsistencias = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(inconsistencias as any[]).map((item, index) => (
|
||||
{(inconsistencias as InconsistenciaItem[]).map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{item.descricao}</div>
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Globe, Play, CheckCircle2, AlertCircle, RefreshCw, ExternalLink, Code } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function WebClone() {
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [activeTab, setActiveTab] = useState('clone');
|
||||
|
||||
const handleClone = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!url) {
|
||||
toast.error('Por favor, insira uma URL válida');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
toast.info(`Iniciando clonagem inteligente de ${url}...`);
|
||||
|
||||
try {
|
||||
// Comunicando com a API do Crawler na porta 3001 (ou rota de gateway)
|
||||
const res = await fetch('http://localhost:3001/api/crawl', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ startUrl: url, maxPages: 5 })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Falha ao comunicar com o serviço de clonagem');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setResult(data);
|
||||
toast.success('Clonagem e recriação concluídas com sucesso!');
|
||||
setActiveTab('resultados');
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.message || 'Erro ao realizar o clone da página');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6 max-w-7xl">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Globe className="h-8 w-8 text-primary" /> WebClone AI
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Clonagem inteligente avançada, mocking de SPAs e recriação generativa de componentes.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="px-3 py-1 text-sm bg-primary/10 text-primary border-primary/20">
|
||||
Playwright + IA + Service Workers
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full space-y-6">
|
||||
<TabsList className="grid w-full md:w-[400px] grid-cols-2">
|
||||
<TabsTrigger value="clone">Nova Clonagem</TabsTrigger>
|
||||
<TabsTrigger value="resultados" disabled={!result}>Resultados</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="clone" className="space-y-6">
|
||||
<Card className="border-border/50 shadow-sm backdrop-blur-sm bg-card/95">
|
||||
<CardHeader>
|
||||
<CardTitle>Iniciar Processo de Clonagem</CardTitle>
|
||||
<CardDescription>
|
||||
Insira a URL do site. O sistema irá extrair o HTML, processar o Shadow DOM, criar mocks de API XHR/Fetch e emular formulários via IA.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleClone} className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Globe className="absolute left-3 top-3 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://exemplo.com.br"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="pl-10 h-12 text-base"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
className="h-12 px-6 font-semibold shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-5 w-5 animate-spin" /> Processando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-5 w-5 fill-current" /> Clonar Site
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="border-border/50 bg-card/50 hover:bg-card/80 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-primary">
|
||||
<CheckCircle2 className="h-5 w-5" /> Mocks de Rede (SPA)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Injeta um Service Worker local para responder chamadas XHR e Fetch gravadas durante a sessão original, permitindo que sites React/Vue funcionem offline.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 bg-card/50 hover:bg-card/80 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-emerald-500">
|
||||
<Code className="h-5 w-5" /> Recriação com IA
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Detecta formulários quebrados ou áreas dinâmicas vazias e usa LLMs multimodais para reescrever o componente em Vanilla JS/CSS funcional.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 bg-card/50 hover:bg-card/80 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-amber-500">
|
||||
<AlertCircle className="h-5 w-5" /> Declarative Shadow DOM
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Vasculha e abre as árvores de Shadow DOM ocultas, convertendo-as para templates declarativos para perfeita fidelidade visual.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="resultados">
|
||||
{result && (
|
||||
<Card className="border-border/50 shadow-sm backdrop-blur-sm bg-card/95">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-6 w-6 text-emerald-500" /> Relatório do Clone
|
||||
</CardTitle>
|
||||
<CardDescription>Detalhes da extração e emulação do site {result.rootUrl}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Páginas Clonadas</div>
|
||||
<div className="text-2xl font-bold text-primary">{result.totalPages || result.pages?.length || 1}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Tempo de Crawl</div>
|
||||
<div className="text-2xl font-bold">{result.crawlTime ? `${(result.crawlTime / 1000).toFixed(1)}s` : 'N/A'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Profundidade Máx</div>
|
||||
<div className="text-2xl font-bold">{result.crawlDepth || 1}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Status</div>
|
||||
<Badge variant="default" className="bg-emerald-500 text-white mt-1">Concluído</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => window.open(`http://localhost:3000`, '_blank')}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/90"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> Acessar Site Clonado Local
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab('clone')}
|
||||
>
|
||||
Clonar Outro Site
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,4 +38,3 @@ export { default as PrioridadesFabricacao } from './PrioridadesFabricacao';
|
||||
export { default as MapaInterativo } from './MapaInterativo';
|
||||
export { default as Atribuicoes } from './Atribuicoes';
|
||||
export { default as VerInconsistencias } from './VerInconsistencias';
|
||||
export { default as WebClone } from './WebClone';
|
||||
|
||||
+84
-101
@@ -2,75 +2,79 @@ import html2canvas from 'html2canvas';
|
||||
import jsPDF from 'jspdf';
|
||||
|
||||
export const generateProfessionalPDF = async (elementId: string, filename: string) => {
|
||||
let tempContainer: HTMLElement | null = null;
|
||||
|
||||
try {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
throw new Error('Elemento não encontrado para gerar PDF');
|
||||
throw new Error(`Elemento #${elementId} não encontrado para gerar PDF`);
|
||||
}
|
||||
|
||||
// Aguardar um pouco para garantir que o elemento esteja completamente renderizado
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
// Criar um container temporário isolado fora de modais/dialogs para evitar bugs de tamanho 0x0
|
||||
tempContainer = document.createElement('div');
|
||||
tempContainer.style.position = 'fixed';
|
||||
tempContainer.style.left = '0';
|
||||
tempContainer.style.top = '0';
|
||||
tempContainer.style.width = '850px';
|
||||
tempContainer.style.backgroundColor = '#ffffff';
|
||||
tempContainer.style.zIndex = '-9999';
|
||||
tempContainer.style.opacity = '1';
|
||||
tempContainer.style.pointerEvents = 'none';
|
||||
tempContainer.style.margin = '0';
|
||||
tempContainer.style.padding = '20px';
|
||||
tempContainer.style.boxSizing = 'border-box';
|
||||
tempContainer.style.overflow = 'visible';
|
||||
|
||||
// Garantir que o elemento esteja visível e com dimensões corretas
|
||||
const originalDisplay = element.style.display;
|
||||
const originalVisibility = element.style.visibility;
|
||||
const originalPosition = element.style.position;
|
||||
// Clonar o elemento para o container isolado
|
||||
const clonedElement = element.cloneNode(true) as HTMLElement;
|
||||
clonedElement.style.display = 'block';
|
||||
clonedElement.style.visibility = 'visible';
|
||||
clonedElement.style.position = 'static';
|
||||
clonedElement.style.width = '100%';
|
||||
clonedElement.style.maxWidth = '100%';
|
||||
clonedElement.style.height = 'auto';
|
||||
clonedElement.style.maxHeight = 'none';
|
||||
clonedElement.style.overflow = 'visible';
|
||||
clonedElement.style.transform = 'none';
|
||||
clonedElement.style.backgroundColor = '#ffffff';
|
||||
clonedElement.style.color = '#000000';
|
||||
|
||||
element.style.display = 'block';
|
||||
element.style.visibility = 'visible';
|
||||
element.style.position = 'relative';
|
||||
tempContainer.appendChild(clonedElement);
|
||||
document.body.appendChild(tempContainer);
|
||||
|
||||
// Forçar um reflow
|
||||
element.offsetHeight;
|
||||
|
||||
// Aguardar mais um pouco após forçar o reflow
|
||||
// Aguardar renderização e computação de layout
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
console.log('Gerando PDF para elemento:', elementId);
|
||||
console.log('Dimensões do elemento:', {
|
||||
width: element.offsetWidth,
|
||||
height: element.offsetHeight,
|
||||
scrollWidth: element.scrollWidth,
|
||||
scrollHeight: element.scrollHeight
|
||||
});
|
||||
|
||||
// Se o elemento não tem dimensões, isso pode causar PDF em branco
|
||||
if (element.offsetWidth === 0 || element.offsetHeight === 0) {
|
||||
throw new Error('Elemento tem dimensões zero - não é possível gerar PDF');
|
||||
}
|
||||
// Elemento a ser renderizado pelo html2canvas
|
||||
const targetElement = (clonedElement.offsetHeight > 0 && clonedElement.offsetWidth > 0)
|
||||
? clonedElement
|
||||
: element;
|
||||
|
||||
// Configurações otimizadas para html2canvas
|
||||
const canvas = await html2canvas(element, {
|
||||
const canvas = await html2canvas(targetElement, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: false,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
width: element.scrollWidth,
|
||||
height: element.scrollHeight,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
windowWidth: Math.max(element.scrollWidth, 1200),
|
||||
windowHeight: Math.max(element.scrollHeight, 800),
|
||||
foreignObjectRendering: false,
|
||||
removeContainer: false,
|
||||
imageTimeout: 10000,
|
||||
logging: false
|
||||
logging: false,
|
||||
windowWidth: 1024,
|
||||
onclone: (clonedDoc) => {
|
||||
const found = clonedDoc.getElementById(elementId) || clonedDoc.body;
|
||||
if (found) {
|
||||
(found as HTMLElement).style.display = 'block';
|
||||
(found as HTMLElement).style.visibility = 'visible';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Canvas criado com sucesso:', {
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
});
|
||||
|
||||
// Verificar se o canvas foi criado corretamente
|
||||
if (canvas.width === 0 || canvas.height === 0) {
|
||||
throw new Error('Canvas criado com dimensões zero');
|
||||
if (!canvas || canvas.width === 0 || canvas.height === 0) {
|
||||
throw new Error('Não foi possível capturar o layout visual para o PDF');
|
||||
}
|
||||
|
||||
// Restaurar estilos originais
|
||||
element.style.display = originalDisplay;
|
||||
element.style.visibility = originalVisibility;
|
||||
element.style.position = originalPosition;
|
||||
const imgData = canvas.toDataURL('image/png', 1.0);
|
||||
if (!imgData || !imgData.startsWith('data:image/png;base64,')) {
|
||||
throw new Error('Falha ao processar os dados da imagem para o PDF');
|
||||
}
|
||||
|
||||
// Criar PDF com configurações otimizadas
|
||||
const pdf = new jsPDF({
|
||||
@@ -85,10 +89,10 @@ export const generateProfessionalPDF = async (elementId: string, filename: strin
|
||||
const pageHeight = 297;
|
||||
|
||||
// Margens adequadas
|
||||
const marginTop = 15;
|
||||
const marginBottom = 15;
|
||||
const marginLeft = 15;
|
||||
const marginRight = 15;
|
||||
const marginTop = 12;
|
||||
const marginBottom = 12;
|
||||
const marginLeft = 12;
|
||||
const marginRight = 12;
|
||||
|
||||
// Área útil para conteúdo
|
||||
const contentWidth = pageWidth - marginLeft - marginRight;
|
||||
@@ -98,103 +102,82 @@ export const generateProfessionalPDF = async (elementId: string, filename: strin
|
||||
const imgWidth = contentWidth;
|
||||
const imgHeight = (canvas.height * contentWidth) / canvas.width;
|
||||
|
||||
// Converter canvas para imagem
|
||||
const imgData = canvas.toDataURL('image/png', 1.0);
|
||||
|
||||
console.log('Adicionando imagem ao PDF:', {
|
||||
imgWidth,
|
||||
imgHeight,
|
||||
contentHeight,
|
||||
totalPages: Math.ceil(imgHeight / contentHeight)
|
||||
});
|
||||
|
||||
// Verificar se os dados da imagem foram gerados
|
||||
if (!imgData || imgData === 'data:,') {
|
||||
throw new Error('Falha ao gerar dados da imagem do canvas');
|
||||
}
|
||||
|
||||
// Sistema de paginação
|
||||
let currentY = 0;
|
||||
let pageNumber = 1;
|
||||
const totalPages = Math.ceil(imgHeight / contentHeight);
|
||||
const totalPages = Math.max(1, Math.ceil(imgHeight / contentHeight));
|
||||
|
||||
// Função para adicionar rodapé com numeração
|
||||
const addFooter = (pageNum: number, totalPages: number) => {
|
||||
const addFooter = (pageNum: number, totalPagesCount: number) => {
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(100, 100, 100);
|
||||
const footerText = `Página ${pageNum} de ${totalPages}`;
|
||||
pdf.setTextColor(120, 120, 120);
|
||||
const footerText = `Página ${pageNum} de ${totalPagesCount}`;
|
||||
const textWidth = pdf.getTextWidth(footerText);
|
||||
const footerX = (pageWidth - textWidth) / 2;
|
||||
const footerY = pageHeight - 8;
|
||||
const footerY = pageHeight - 6;
|
||||
pdf.text(footerText, footerX, footerY);
|
||||
};
|
||||
|
||||
// Primeira página
|
||||
if (imgHeight <= contentHeight) {
|
||||
// Conteúdo cabe em uma página
|
||||
pdf.addImage(imgData, 'PNG', marginLeft, marginTop, imgWidth, imgHeight);
|
||||
pdf.addImage(imgData, 'PNG', marginLeft, marginTop, imgWidth, imgHeight, undefined, 'FAST');
|
||||
addFooter(1, 1);
|
||||
} else {
|
||||
// Conteúdo precisa de múltiplas páginas
|
||||
// Conteúdo precisa de múltiplas páginas com corte preciso
|
||||
while (currentY < imgHeight) {
|
||||
if (pageNumber > 1) {
|
||||
pdf.addPage();
|
||||
}
|
||||
|
||||
// Calcular a altura restante do conteúdo
|
||||
const remainingHeight = imgHeight - currentY;
|
||||
const currentPageHeight = Math.min(contentHeight, remainingHeight);
|
||||
|
||||
// Criar um canvas temporário para a seção atual
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
if (tempCtx) {
|
||||
if (tempCtx && currentPageHeight > 0) {
|
||||
const sliceHeight = Math.round((currentPageHeight * canvas.width) / imgWidth);
|
||||
tempCanvas.width = canvas.width;
|
||||
tempCanvas.height = (currentPageHeight * canvas.width) / imgWidth;
|
||||
tempCanvas.height = sliceHeight;
|
||||
|
||||
tempCtx.fillStyle = '#ffffff';
|
||||
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
|
||||
// Desenhar a seção atual do canvas original
|
||||
tempCtx.drawImage(
|
||||
canvas,
|
||||
0,
|
||||
(currentY * canvas.width) / imgWidth,
|
||||
Math.round((currentY * canvas.width) / imgWidth),
|
||||
canvas.width,
|
||||
tempCanvas.height,
|
||||
sliceHeight,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
tempCanvas.height
|
||||
sliceHeight
|
||||
);
|
||||
|
||||
// Converter para dados de imagem
|
||||
const tempImgData = tempCanvas.toDataURL('image/png', 1.0);
|
||||
|
||||
// Adicionar a seção ao PDF
|
||||
pdf.addImage(tempImgData, 'PNG', marginLeft, marginTop, imgWidth, currentPageHeight);
|
||||
if (tempImgData && tempImgData.startsWith('data:image/png;base64,')) {
|
||||
pdf.addImage(tempImgData, 'PNG', marginLeft, marginTop, imgWidth, currentPageHeight, undefined, 'FAST');
|
||||
}
|
||||
}
|
||||
|
||||
// Adicionar rodapé
|
||||
addFooter(pageNumber, totalPages);
|
||||
|
||||
// Preparar para próxima página
|
||||
currentY += contentHeight;
|
||||
pageNumber++;
|
||||
}
|
||||
}
|
||||
|
||||
// Forçar o download do PDF
|
||||
console.log('Iniciando download do PDF:', filename);
|
||||
// Salvar arquivo PDF
|
||||
pdf.save(filename);
|
||||
|
||||
// Aguardar um momento para garantir que o download seja iniciado
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
console.log('PDF salvo com sucesso:', filename);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Erro detalhado ao gerar PDF:', error);
|
||||
throw new Error(`Erro ao gerar PDF: ${error.message}`);
|
||||
throw new Error(`Erro ao gerar PDF: ${error.message || error}`);
|
||||
} finally {
|
||||
if (tempContainer && tempContainer.parentNode) {
|
||||
tempContainer.parentNode.removeChild(tempContainer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
|
||||
const LOGTO_ENDPOINT = "https://logto-bzlued1boxl3t8ewsyn99an9.187.77.227.172.sslip.io";
|
||||
const APP_ID = "rv73s8it14dkk6pdxegpy";
|
||||
const APP_SECRET = "mB108PqAEa2pgTqNrBBdnVer12Sgf0pT";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { email } = await req.json();
|
||||
|
||||
if (!email) {
|
||||
return new Response(JSON.stringify({ error: "Email is required" }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Get Logto M2M Access Token
|
||||
const tokenResponse = await fetch(`${LOGTO_ENDPOINT}/oidc/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${btoa(`${APP_ID}:${APP_SECRET}`)}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
resource: "https://default.logto.app/api",
|
||||
scope: "all",
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
throw new Error(`Failed to get M2M token: ${errorText}`);
|
||||
}
|
||||
|
||||
const { access_token } = await tokenResponse.json();
|
||||
|
||||
// 2. Find User by Email
|
||||
const searchResponse = await fetch(`${LOGTO_ENDPOINT}/api/users?search=${encodeURIComponent(email)}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!searchResponse.ok) {
|
||||
throw new Error(`Failed to search user: ${await searchResponse.text()}`);
|
||||
}
|
||||
|
||||
const users = await searchResponse.json();
|
||||
if (!users || users.length === 0) {
|
||||
return new Response(JSON.stringify({ message: "User not found in Logto" }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
const logtoUserId = users[0].id;
|
||||
|
||||
// 3. Delete User in Logto
|
||||
const deleteResponse = await fetch(`${LOGTO_ENDPOINT}/api/users/${logtoUserId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!deleteResponse.ok) {
|
||||
throw new Error(`Failed to delete user: ${await deleteResponse.text()}`);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ message: "User deleted successfully in Logto" }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting Logto user:", error);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Migration para corrigir a trigger atualizar_datas_reais_processo e prevenir erros 409 (Conflict) em inserções concorrentes de apontamentos
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.atualizar_datas_reais_processo()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
total_produzido NUMERIC;
|
||||
total_planejado NUMERIC;
|
||||
BEGIN
|
||||
-- Se peca_id for nulo (ex: apontamento de componente sem peca_id vinculada), ignorar processamento de datas por peça
|
||||
IF NEW.peca_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Buscar quantidade total planejada da peça
|
||||
SELECT COALESCE(quantidade, 0) INTO total_planejado
|
||||
FROM public.pecas
|
||||
WHERE id = NEW.peca_id;
|
||||
|
||||
-- Inserir novo registro ou atualizar existente usando ON CONFLICT para evitar erro 409 (Constraint UNIQUE peca_id + processo_id)
|
||||
INSERT INTO public.processos_pecas_datas (
|
||||
peca_id,
|
||||
processo_id,
|
||||
data_inicio_real,
|
||||
quantidade_total_planejada,
|
||||
quantidade_total_produzida
|
||||
) VALUES (
|
||||
NEW.peca_id,
|
||||
NEW.processo_id,
|
||||
NEW.data_apontamento,
|
||||
total_planejado,
|
||||
NEW.quantidade_produzida
|
||||
)
|
||||
ON CONFLICT (peca_id, processo_id) DO UPDATE
|
||||
SET
|
||||
quantidade_total_produzida = (
|
||||
SELECT COALESCE(SUM(quantidade_produzida), 0)
|
||||
FROM public.apontamentos_producao
|
||||
WHERE peca_id = NEW.peca_id AND processo_id = NEW.processo_id
|
||||
),
|
||||
data_conclusao_real = CASE
|
||||
WHEN (
|
||||
SELECT COALESCE(SUM(quantidade_produzida), 0)
|
||||
FROM public.apontamentos_producao
|
||||
WHERE peca_id = NEW.peca_id AND processo_id = NEW.processo_id
|
||||
) >= processos_pecas_datas.quantidade_total_planejada THEN NEW.data_apontamento
|
||||
ELSE processos_pecas_datas.data_conclusao_real
|
||||
END,
|
||||
updated_at = now();
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,14 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT conrelid::regclass AS table_name, conname AS constraint_name
|
||||
FROM pg_constraint
|
||||
WHERE confrelid = 'auth.users'::regclass
|
||||
AND conrelid::regclass::text LIKE '"TS_ERP"%'
|
||||
) LOOP
|
||||
EXECUTE 'ALTER TABLE ' || r.table_name || ' DROP CONSTRAINT IF EXISTS ' || r.constraint_name || ' CASCADE;';
|
||||
RAISE NOTICE 'Dropped constraint % on %', r.constraint_name, r.table_name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Change pecas.peso_unitario to numeric(10,3)
|
||||
ALTER TABLE "TS_ERP".pecas ALTER COLUMN peso_unitario TYPE numeric(10,3);
|
||||
|
||||
-- Fix the sync_of_data function to explicitly refer to TS_ERP.ordens_fabricacao
|
||||
CREATE OR REPLACE FUNCTION "TS_ERP".sync_of_data()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
-- Atualizar dados na tabela ordens_fabricacao quando ficha_tecnica_contratos for alterada
|
||||
IF TG_TABLE_NAME = 'ficha_tecnica_contratos' THEN
|
||||
UPDATE "TS_ERP".ordens_fabricacao
|
||||
SET
|
||||
gestor = NEW.gestor,
|
||||
data_termino_prev = NEW.data_termino_prev,
|
||||
peso_total = NEW.quantidade,
|
||||
descritivo = NEW.descricao_resumida
|
||||
WHERE num_of = NEW.of_number;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Remover o gatilho que incrementava automaticamente a revisão do quadro de prioridades
|
||||
DROP TRIGGER IF EXISTS trigger_increment_prioridade_fabricacao_revision ON "TS_ERP".itens_prioridade_fabricacao;
|
||||
DROP TRIGGER IF EXISTS trigger_increment_prioridade_fabricacao_revision ON public.itens_prioridade_fabricacao;
|
||||
|
||||
-- Remover a função associada
|
||||
DROP FUNCTION IF EXISTS public.increment_prioridade_fabricacao_revision();
|
||||
+16
@@ -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
@@ -0,0 +1,17 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import fs from 'fs';
|
||||
|
||||
const envContent = fs.readFileSync('.env', 'utf8');
|
||||
const supabaseUrl = envContent.match(/VITE_SUPABASE_URL="(.*?)"/)[1];
|
||||
const supabaseKey = envContent.match(/VITE_SUPABASE_PUBLISHABLE_KEY="(.*?)"/)[1];
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
async function main() {
|
||||
const { data: ofData } = await supabase.from('ordens_fabricacao').select('*').limit(3);
|
||||
console.log('OFs:', ofData.map(o => ({ num_of: o.num_of, peso_total: o.peso_total })));
|
||||
|
||||
const { data: pecas } = await supabase.from('pecas').select('of_number, peso_unitario, peso_total, quantidade').limit(3);
|
||||
console.log('Pecas:', pecas);
|
||||
}
|
||||
main();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user