Compare commits
78 Commits
278f0f4491
..
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 |
@@ -21,10 +21,18 @@ COPY . .
|
|||||||
ARG VITE_SUPABASE_URL
|
ARG VITE_SUPABASE_URL
|
||||||
ARG VITE_SUPABASE_PUBLISHABLE_KEY
|
ARG VITE_SUPABASE_PUBLISHABLE_KEY
|
||||||
ARG VITE_SUPABASE_PROJECT_ID
|
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_URL=$VITE_SUPABASE_URL
|
||||||
ENV VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY
|
ENV VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY
|
||||||
ENV VITE_SUPABASE_PROJECT_ID=$VITE_SUPABASE_PROJECT_ID
|
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
|
# Build de produção
|
||||||
RUN npm run build
|
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.
|
||||||
@@ -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,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 React from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
|
|
||||||
|
import { useUserProfile } from '@/hooks/useUserProfile';
|
||||||
|
|
||||||
interface ProtectedAdminRouteProps {
|
interface ProtectedAdminRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ children }) => {
|
export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ children }) => {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||||
|
const { profile, loading: profileLoading } = useUserProfile();
|
||||||
|
|
||||||
if (loading || roleLoading) {
|
const loading = authLoading || roleLoading || profileLoading;
|
||||||
return <div>Carregando...</div>;
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||||
|
<div className="text-muted-foreground">Carregando...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/auth" replace />;
|
return <Navigate to="/auth" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verificar se o usuário está ativo no sistema
|
||||||
|
if (profile && profile.status !== 'active') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
|
||||||
|
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
|
||||||
|
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,20 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useUserProfile } from '@/hooks/useUserProfile';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { logger } from '@/utils/logger';
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const { profile, loading: profileLoading } = useUserProfile();
|
||||||
|
|
||||||
// Buscar o perfil do usuário para verificar o status
|
const loading = authLoading || profileLoading;
|
||||||
const { data: profile, isLoading: profileLoading, error } = useQuery({
|
|
||||||
queryKey: ['user-profile', user?.id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!user?.id) return null;
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
// Mostrar loading enquanto carrega autenticação
|
||||||
.from('profiles')
|
if (loading) {
|
||||||
.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)) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||||
<div className="text-muted-foreground">Carregando...</div>
|
<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
|
// Se não há usuário, redirecionar para auth
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.debug('ProtectedRoute: Usuário não autenticado, redirecionando para /auth');
|
|
||||||
return <Navigate to="/auth" replace />;
|
return <Navigate to="/auth" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se há erro ao carregar perfil, permitir acesso (para evitar loop)
|
// Verificar se o usuário está ativo no sistema
|
||||||
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'
|
|
||||||
if (profile && profile.status !== 'active') {
|
if (profile && profile.status !== 'active') {
|
||||||
logger.debug('ProtectedRoute: Usuário com status inválido', profile.status as any);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
|
||||||
<div className="text-center space-y-4 p-8 max-w-md mx-auto">
|
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
|
||||||
<div className="text-6xl">⏳</div>
|
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
|
||||||
<h2 className="text-2xl font-semibold text-foreground">
|
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('ProtectedRoute: Usuário ativo autorizado, renderizando conteúdo');
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useUserPermissions } from '@/hooks/useUserPermissions';
|
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
import { useUserRole } from '@/hooks/useUserRole';
|
||||||
import { logger } from '@/utils/logger';
|
|
||||||
|
import { useUserProfile } from '@/hooks/useUserProfile';
|
||||||
|
|
||||||
interface ProtectedRouteByResourceProps {
|
interface ProtectedRouteByResourceProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -13,30 +12,14 @@ interface ProtectedRouteByResourceProps {
|
|||||||
|
|
||||||
export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> = ({
|
export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> = ({
|
||||||
children,
|
children,
|
||||||
resourceKey
|
|
||||||
}) => {
|
}) => {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||||
|
const { profile, loading: profileLoading } = useUserProfile();
|
||||||
|
|
||||||
// Use a try-catch to prevent the hook from crashing the component
|
const loading = authLoading || roleLoading || profileLoading;
|
||||||
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 { hasAccess, loading: permissionsLoading, userPermissions, getResourcePermission } = permissionsData;
|
if (loading) {
|
||||||
|
|
||||||
// Aguardar carregamento
|
|
||||||
if (loading || permissionsLoading || roleLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-muted-foreground">Carregando...</div>
|
<div className="text-muted-foreground">Carregando...</div>
|
||||||
@@ -44,87 +27,22 @@ export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirecionar para login se não autenticado
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/auth" replace />;
|
return <Navigate to="/auth" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin sempre tem acesso (exceto se explicitamente negado)
|
// Verificar se o usuário está ativo no sistema
|
||||||
if (isAdmin) {
|
if (profile && profile.status !== 'active') {
|
||||||
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}`);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
|
||||||
<div className="text-center space-y-4">
|
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
|
||||||
<div className="text-6xl">🔒</div>
|
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
|
||||||
<h2 className="text-2xl font-semibold text-muted-foreground">
|
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migração pro Logto: todos usuários autenticados têm acesso aos recursos.
|
||||||
|
// Sistema de permissões granulares fica pra depois.
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
@@ -13,7 +13,6 @@ import { usePecas } from '@/hooks/usePecas';
|
|||||||
import { useOFs } from '@/hooks/useOFs';
|
import { useOFs } from '@/hooks/useOFs';
|
||||||
import { useComponentesAgrupados } from '@/hooks/useComponentesAgrupados';
|
import { useComponentesAgrupados } from '@/hooks/useComponentesAgrupados';
|
||||||
import { SeletorItensOtimizado } from './SeletorItensOtimizado';
|
import { SeletorItensOtimizado } from './SeletorItensOtimizado';
|
||||||
import { useApontamentosValidacao } from '@/hooks/useApontamentosValidacao';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface ItemDisponivel {
|
interface ItemDisponivel {
|
||||||
@@ -23,9 +22,10 @@ interface ItemDisponivel {
|
|||||||
tipo: 'peca' | 'componente';
|
tipo: 'peca' | 'componente';
|
||||||
quantidade_disponivel: number;
|
quantidade_disponivel: number;
|
||||||
processo_atual_permitido: number;
|
processo_atual_permitido: number;
|
||||||
|
nome_processo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache para manter seleções do usuário
|
// Cache local para manter seleções básicas do formulário
|
||||||
const formCache = {
|
const formCache = {
|
||||||
of_number: '',
|
of_number: '',
|
||||||
fase: '',
|
fase: '',
|
||||||
@@ -33,13 +33,6 @@ const formCache = {
|
|||||||
data_apontamento: new Date().toISOString().split('T')[0]
|
data_apontamento: new Date().toISOString().split('T')[0]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache para itens já processados
|
|
||||||
const itensCache = new Map<string, {
|
|
||||||
pecasDisponiveis: ItemDisponivel[];
|
|
||||||
componentesDisponiveis: ItemDisponivel[];
|
|
||||||
timestamp: number;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
export const ApontamentoForm = () => {
|
export const ApontamentoForm = () => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
of_number: formCache.of_number || '',
|
of_number: formCache.of_number || '',
|
||||||
@@ -52,55 +45,40 @@ export const ApontamentoForm = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
|
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
|
||||||
const [itensDisponiveis, setItensDisponiveis] = useState<{
|
|
||||||
pecasDisponiveis: ItemDisponivel[];
|
|
||||||
componentesDisponiveis: ItemDisponivel[];
|
|
||||||
}>({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [cacheValido, setCacheValido] = useState(false);
|
const [cacheValido, setCacheValido] = useState(false);
|
||||||
const [loadingItens, setLoadingItens] = useState(false);
|
|
||||||
const [isProcessingItems, setIsProcessingItems] = useState(false);
|
|
||||||
|
|
||||||
const { criarApontamento, refetch, processos } = useApontamentosProducao();
|
const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos } = useApontamentosProducao();
|
||||||
const { pecas } = usePecas();
|
const { pecas, loading: loadingPecas } = usePecas();
|
||||||
const { ofs } = useOFs();
|
const { ofs } = useOFs();
|
||||||
const { componentesAgrupados } = useComponentesAgrupados(formData.of_number, formData.fase);
|
const { componentesAgrupados, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
|
||||||
const {
|
|
||||||
validarSequenciaProcessos,
|
|
||||||
precarregarDados,
|
|
||||||
limparCache: limparCacheValidacao
|
|
||||||
} = useApontamentosValidacao();
|
|
||||||
|
|
||||||
// Buscar fases únicas da OF selecionada
|
// Buscar fases únicas da OF selecionada
|
||||||
const fasesDisponiveis = useMemo(() =>
|
const fasesDisponiveis = useMemo(() => {
|
||||||
|
if (!formData.of_number || !pecas.length) return [];
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
pecas
|
pecas
|
||||||
.filter(peca => peca.of_number === formData.of_number)
|
.filter(peca => peca.of_number === formData.of_number)
|
||||||
.map(peca => peca.etapa_fase)
|
.map(peca => peca.etapa_fase)
|
||||||
.filter((fase, index, array) => fase && array.indexOf(fase) === index)
|
.filter(Boolean)
|
||||||
.sort(),
|
)
|
||||||
[pecas, formData.of_number]
|
).sort();
|
||||||
);
|
}, [pecas, formData.of_number]);
|
||||||
|
|
||||||
// Peças filtradas - memoizado para evite recálculos
|
// Processo selecionado atualmente
|
||||||
const filteredPecas = useMemo(() =>
|
const processoSelecionado = useMemo(() => {
|
||||||
pecas.filter(peca =>
|
return processos.find(p => p.id === formData.processo_id) || null;
|
||||||
peca.of_number === formData.of_number &&
|
}, [processos, formData.processo_id]);
|
||||||
peca.etapa_fase === formData.fase &&
|
|
||||||
!peca.tem_componentes
|
|
||||||
),
|
|
||||||
[pecas, formData.of_number, formData.fase]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Chave única para cache
|
|
||||||
const cacheKey = useMemo(() =>
|
|
||||||
`${formData.of_number}_${formData.fase}_${formData.processo_id}`,
|
|
||||||
[formData.of_number, formData.fase, formData.processo_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Salvar cache quando seleções básicas mudam
|
// Salvar cache quando seleções básicas mudam
|
||||||
const updateCache = useCallback((updates: Partial<typeof formData>) => {
|
const updateCache = useCallback((updates: Partial<typeof formData>) => {
|
||||||
Object.assign(formCache, updates);
|
Object.assign(formCache, updates);
|
||||||
|
try {
|
||||||
localStorage.setItem('apontamento_cache', JSON.stringify(formCache));
|
localStorage.setItem('apontamento_cache', JSON.stringify(formCache));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Erro ao salvar cache:', e);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Carregar cache inicial
|
// Carregar cache inicial
|
||||||
@@ -124,120 +102,129 @@ export const ApontamentoForm = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Função para processar itens com cache
|
// Cálculo reativo de peças e componentes disponíveis para o processo selecionado
|
||||||
const processarItensDisponiveis = useCallback(async () => {
|
const itensDisponiveis = useMemo(() => {
|
||||||
const { of_number, fase, processo_id } = formData;
|
const { of_number, fase, processo_id } = formData;
|
||||||
|
|
||||||
if (!of_number || !fase || !processo_id) {
|
if (!of_number || !fase || !processo_id || !pecas.length) {
|
||||||
console.log('⚠️ Campos obrigatórios faltando para carregar itens');
|
return { pecasDisponiveis: [], componentesDisponiveis: [] };
|
||||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar se já temos no cache (válido por 30 segundos)
|
const ordemProcesso = processoSelecionado?.ordem || 1;
|
||||||
const cached = itensCache.get(cacheKey);
|
const nomeProcesso = processoSelecionado?.nome || `${ordemProcesso}`;
|
||||||
const now = Date.now();
|
|
||||||
if (cached && (now - cached.timestamp) < 30000) {
|
// Reordenar processos para identificar sequencialidade
|
||||||
console.log('📦 Usando itens do cache');
|
const processosOrdenados = [...processos].sort((a, b) => a.ordem - b.ordem);
|
||||||
setItensDisponiveis(cached);
|
const indexAtual = processosOrdenados.findIndex(p => p.id === processo_id);
|
||||||
return;
|
const procAtual = processosOrdenados[indexAtual];
|
||||||
|
|
||||||
|
// 1. Peças da OF e Fase selecionadas
|
||||||
|
const pecasDaFase = pecas.filter(
|
||||||
|
p => p.of_number === of_number && p.etapa_fase === fase
|
||||||
|
);
|
||||||
|
|
||||||
|
const pecasDisponiveis: ItemDisponivel[] = [];
|
||||||
|
|
||||||
|
pecasDaFase.forEach(peca => {
|
||||||
|
// Calcular quanto já foi apontado desta peça neste processo
|
||||||
|
const totalApontadoAtual = apontamentos
|
||||||
|
.filter(a => a.tipo_apontamento === 'peca' && a.peca_id === peca.id && a.processo_id === processo_id)
|
||||||
|
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||||
|
|
||||||
|
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
|
||||||
|
|
||||||
|
// Regra: peças sem componentes não passam por solda
|
||||||
|
if (!peca.tem_componentes && procAtual && procAtual.nome.toLowerCase().includes('solda')) {
|
||||||
|
qtdDisponivelParaEntrar = 0;
|
||||||
|
} else if (indexAtual > 0) {
|
||||||
|
// Encontrar processo anterior válido
|
||||||
|
let processoAnteriorValido = null;
|
||||||
|
for (let i = indexAtual - 1; i >= 0; i--) {
|
||||||
|
const p = processosOrdenados[i];
|
||||||
|
if (!peca.tem_componentes && p.nome.toLowerCase().includes('solda')) {
|
||||||
|
continue; // Pula a solda na busca do processo anterior para peças simples
|
||||||
|
}
|
||||||
|
processoAnteriorValido = p;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aguardar dados das peças e componentes
|
if (processoAnteriorValido) {
|
||||||
if (filteredPecas.length === 0 && componentesAgrupados.length === 0) {
|
qtdDisponivelParaEntrar = apontamentos
|
||||||
console.log('⏳ Aguardando dados de peças e componentes...');
|
.filter(a => a.tipo_apontamento === 'peca' && a.peca_id === peca.id && a.processo_id === processoAnteriorValido.id)
|
||||||
return;
|
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isProcessingItems) {
|
const saldoDisponivel = Math.max(0, qtdDisponivelParaEntrar - totalApontadoAtual);
|
||||||
console.log('🔄 Já processando itens, aguardando...');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n🚀 === PROCESSANDO ITENS COM CACHE ===');
|
if (saldoDisponivel > 0) {
|
||||||
console.log(`📋 OF: ${of_number}, Fase: ${fase}, Processo: ${processo_id}`);
|
pecasDisponiveis.push({
|
||||||
|
|
||||||
setIsProcessingItems(true);
|
|
||||||
setLoadingItens(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Calcular itens disponíveis baseado nos dados existentes
|
|
||||||
console.log('🧮 Calculando itens disponíveis...');
|
|
||||||
const itens = {
|
|
||||||
pecasDisponiveis: filteredPecas.map(peca => ({
|
|
||||||
id: peca.id,
|
id: peca.id,
|
||||||
marca: peca.marca,
|
marca: peca.marca,
|
||||||
descricao: peca.descricao,
|
descricao: peca.descricao || '',
|
||||||
tipo: 'peca' as const,
|
tipo: 'peca',
|
||||||
quantidade_disponivel: peca.quantidade,
|
quantidade_disponivel: saldoDisponivel,
|
||||||
processo_atual_permitido: 1
|
processo_atual_permitido: ordemProcesso,
|
||||||
})),
|
nome_processo: nomeProcesso
|
||||||
componentesDisponiveis: componentesAgrupados.map(comp => ({
|
});
|
||||||
id: comp.componente_ids[0] || '', // Use primeiro ID do array
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Componentes da OF e Fase selecionadas
|
||||||
|
const componentesDisponiveis: ItemDisponivel[] = [];
|
||||||
|
|
||||||
|
if (componentesAgrupados && componentesAgrupados.length > 0) {
|
||||||
|
componentesAgrupados.forEach(comp => {
|
||||||
|
let qtdDisponivelParaEntrarComp = Number(comp.quantidade_total) || 0;
|
||||||
|
|
||||||
|
if (indexAtual > 0) {
|
||||||
|
const processoAnteriorValido = processosOrdenados[indexAtual - 1];
|
||||||
|
if (processoAnteriorValido) {
|
||||||
|
qtdDisponivelParaEntrarComp = apontamentos
|
||||||
|
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processoAnteriorValido.id)
|
||||||
|
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalApontadoComp = apontamentos
|
||||||
|
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processo_id)
|
||||||
|
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||||
|
|
||||||
|
const saldoComp = Math.max(0, qtdDisponivelParaEntrarComp - totalApontadoComp);
|
||||||
|
|
||||||
|
if (saldoComp > 0) {
|
||||||
|
componentesDisponiveis.push({
|
||||||
|
id: comp.componente_ids[0] || '',
|
||||||
marca: comp.marca_componente,
|
marca: comp.marca_componente,
|
||||||
descricao: comp.descricao || '',
|
descricao: comp.descricao || comp.perfil || '',
|
||||||
tipo: 'componente' as const,
|
tipo: 'componente',
|
||||||
quantidade_disponivel: comp.quantidade_total,
|
quantidade_disponivel: saldoComp,
|
||||||
processo_atual_permitido: 1
|
processo_atual_permitido: ordemProcesso,
|
||||||
}))
|
nome_processo: nomeProcesso
|
||||||
};
|
|
||||||
|
|
||||||
console.log('✅ Itens calculados:', {
|
|
||||||
pecas: itens.pecasDisponiveis.length,
|
|
||||||
componentes: itens.componentesDisponiveis.length
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// 4. Salvar no cache
|
|
||||||
itensCache.set(cacheKey, {
|
|
||||||
...itens,
|
|
||||||
timestamp: now
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Atualizar estado
|
|
||||||
setItensDisponiveis(itens);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erro ao processar itens:', error);
|
|
||||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
|
||||||
} finally {
|
|
||||||
setLoadingItens(false);
|
|
||||||
setIsProcessingItems(false);
|
|
||||||
}
|
}
|
||||||
}, [
|
|
||||||
formData.of_number,
|
|
||||||
formData.fase,
|
|
||||||
formData.processo_id
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Callback para atualizar dados
|
return { pecasDisponiveis, componentesDisponiveis };
|
||||||
const updateData = useCallback(() => {
|
}, [formData, pecas, apontamentos, processoSelecionado, componentesAgrupados, processos]);
|
||||||
// 4. Atualizar dados para nova seleção
|
|
||||||
console.log('✅ Dados atualizados para nova seleção de OF/processo');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Efeito controlado para carregar itens
|
// Sincronizar item selecionado caso não exista mais na lista disponível
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (formData.of_number && formData.fase && formData.processo_id) {
|
if (itemSelecionado) {
|
||||||
// Usar timeout para evitar chamadas excessivas
|
const listaAtual = itemSelecionado.tipo === 'peca'
|
||||||
const timeoutId = setTimeout(() => {
|
? itensDisponiveis.pecasDisponiveis
|
||||||
processarItensDisponiveis();
|
: itensDisponiveis.componentesDisponiveis;
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
const itemAindaExiste = listaAtual.find(i => i.id === itemSelecionado.id);
|
||||||
} else {
|
if (!itemAindaExiste) {
|
||||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
|
||||||
}
|
|
||||||
}, [formData.of_number, formData.fase, formData.processo_id]);
|
|
||||||
|
|
||||||
// Reset do item selecionado quando dados mudam
|
|
||||||
useEffect(() => {
|
|
||||||
setItemSelecionado(null);
|
setItemSelecionado(null);
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({ ...prev, quantidade_produzida: '', todas_disponiveis: false }));
|
||||||
...prev,
|
} else if (itemAindaExiste.quantidade_disponivel !== itemSelecionado.quantidade_disponivel) {
|
||||||
quantidade_produzida: '',
|
setItemSelecionado(itemAindaExiste);
|
||||||
todas_disponiveis: false
|
}
|
||||||
}));
|
}
|
||||||
}, [formData.of_number, formData.fase, formData.processo_id]);
|
}, [itensDisponiveis, itemSelecionado]);
|
||||||
|
|
||||||
// Auto-preenchimento da quantidade quando "todas disponíveis" é marcado
|
// Auto-preenchimento da quantidade quando "todas disponíveis" é marcado
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -259,9 +246,9 @@ export const ApontamentoForm = () => {
|
|||||||
const tipoTexto = tipo === 'peca' ? 'peças' : 'componentes';
|
const tipoTexto = tipo === 'peca' ? 'peças' : 'componentes';
|
||||||
|
|
||||||
const confirmacao = window.confirm(
|
const confirmacao = window.confirm(
|
||||||
`Deseja registrar ${totalItens} ${tipoTexto} com suas respectivas quantidades totais?\n\n` +
|
`Deseja registrar ${totalItens} ${tipoTexto} com suas respectivas quantidades totais disponíveis?\n\n` +
|
||||||
`Total de itens: ${totalItens}\n` +
|
`Total de itens: ${totalItens}\n` +
|
||||||
`Processo: ${formData.processo_id || 'N/A'}`
|
`Processo: ${processoSelecionado?.nome || 'N/A'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!confirmacao) return;
|
if (!confirmacao) return;
|
||||||
@@ -273,7 +260,7 @@ export const ApontamentoForm = () => {
|
|||||||
try {
|
try {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
try {
|
try {
|
||||||
const apontamentoData: any = {
|
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
|
||||||
of_number: formData.of_number,
|
of_number: formData.of_number,
|
||||||
tipo_apontamento: item.tipo,
|
tipo_apontamento: item.tipo,
|
||||||
processo_id: formData.processo_id,
|
processo_id: formData.processo_id,
|
||||||
@@ -303,11 +290,8 @@ export const ApontamentoForm = () => {
|
|||||||
|
|
||||||
if (sucessos > 0) {
|
if (sucessos > 0) {
|
||||||
toast.success(`${sucessos} ${tipoTexto} registradas com sucesso!${erros > 0 ? ` (${erros} com erro)` : ''}`);
|
toast.success(`${sucessos} ${tipoTexto} registradas com sucesso!${erros > 0 ? ` (${erros} com erro)` : ''}`);
|
||||||
|
await refetch();
|
||||||
await Promise.all([
|
resetFormForNewEntry();
|
||||||
refetch(),
|
|
||||||
resetFormForNewEntry()
|
|
||||||
]);
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(`Erro ao registrar ${tipoTexto} em lote`);
|
toast.error(`Erro ao registrar ${tipoTexto} em lote`);
|
||||||
}
|
}
|
||||||
@@ -331,9 +315,6 @@ export const ApontamentoForm = () => {
|
|||||||
setFormData(prev => ({ ...prev, ...updates }));
|
setFormData(prev => ({ ...prev, ...updates }));
|
||||||
updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
|
updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
|
||||||
setItemSelecionado(null);
|
setItemSelecionado(null);
|
||||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
|
||||||
// Limpar cache relacionado
|
|
||||||
itensCache.clear();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFaseChange = (fase: string) => {
|
const handleFaseChange = (fase: string) => {
|
||||||
@@ -346,9 +327,6 @@ export const ApontamentoForm = () => {
|
|||||||
setFormData(prev => ({ ...prev, ...updates }));
|
setFormData(prev => ({ ...prev, ...updates }));
|
||||||
updateCache({ fase: fase, processo_id: '' });
|
updateCache({ fase: fase, processo_id: '' });
|
||||||
setItemSelecionado(null);
|
setItemSelecionado(null);
|
||||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
|
||||||
// Limpar cache relacionado
|
|
||||||
itensCache.clear();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProcessoChange = (processoId: string) => {
|
const handleProcessoChange = (processoId: string) => {
|
||||||
@@ -360,7 +338,6 @@ export const ApontamentoForm = () => {
|
|||||||
setFormData(prev => ({ ...prev, ...updates }));
|
setFormData(prev => ({ ...prev, ...updates }));
|
||||||
updateCache({ processo_id: processoId });
|
updateCache({ processo_id: processoId });
|
||||||
setItemSelecionado(null);
|
setItemSelecionado(null);
|
||||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleItemSelect = (item: ItemDisponivel) => {
|
const handleItemSelect = (item: ItemDisponivel) => {
|
||||||
@@ -387,10 +364,7 @@ export const ApontamentoForm = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Função para resetar form e atualizar dados
|
const resetFormForNewEntry = () => {
|
||||||
const resetFormForNewEntry = async () => {
|
|
||||||
console.log('🔄 Resetando formulário e limpando cache...');
|
|
||||||
|
|
||||||
setItemSelecionado(null);
|
setItemSelecionado(null);
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -398,9 +372,6 @@ export const ApontamentoForm = () => {
|
|||||||
observacoes: '',
|
observacoes: '',
|
||||||
todas_disponiveis: false
|
todas_disponiveis: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Limpar cache e forçar recarregamento
|
|
||||||
itensCache.clear();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -413,7 +384,7 @@ export const ApontamentoForm = () => {
|
|||||||
|
|
||||||
const quantidade = parseInt(formData.quantidade_produzida);
|
const quantidade = parseInt(formData.quantidade_produzida);
|
||||||
|
|
||||||
if (quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
|
if (isNaN(quantidade) || quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
|
||||||
toast.error('Quantidade inválida');
|
toast.error('Quantidade inválida');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -421,16 +392,13 @@ export const ApontamentoForm = () => {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validação básica - pode ser expandida depois
|
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
|
||||||
console.log('✅ Validação de sequência aprovada');
|
|
||||||
|
|
||||||
const apontamentoData: any = {
|
|
||||||
of_number: formData.of_number,
|
of_number: formData.of_number,
|
||||||
tipo_apontamento: itemSelecionado.tipo,
|
tipo_apontamento: itemSelecionado.tipo,
|
||||||
processo_id: formData.processo_id,
|
processo_id: formData.processo_id,
|
||||||
quantidade_produzida: quantidade,
|
quantidade_produzida: quantidade,
|
||||||
data_apontamento: formData.data_apontamento,
|
data_apontamento: formData.data_apontamento,
|
||||||
observacoes: formData.observacoes || null
|
observacoes: formData.observacoes || undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
if (itemSelecionado.tipo === 'componente') {
|
if (itemSelecionado.tipo === 'componente') {
|
||||||
@@ -443,11 +411,8 @@ export const ApontamentoForm = () => {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success('Apontamento registrado com sucesso!');
|
toast.success('Apontamento registrado com sucesso!');
|
||||||
|
await refetch();
|
||||||
await Promise.all([
|
resetFormForNewEntry();
|
||||||
refetch(),
|
|
||||||
resetFormForNewEntry()
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro no submit:', error);
|
console.error('Erro no submit:', error);
|
||||||
@@ -475,13 +440,11 @@ export const ApontamentoForm = () => {
|
|||||||
todas_disponiveis: false
|
todas_disponiveis: false
|
||||||
});
|
});
|
||||||
setItemSelecionado(null);
|
setItemSelecionado(null);
|
||||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
|
||||||
setCacheValido(false);
|
setCacheValido(false);
|
||||||
itensCache.clear();
|
|
||||||
toast.success('Cache limpo com sucesso!');
|
toast.success('Cache limpo com sucesso!');
|
||||||
};
|
};
|
||||||
|
|
||||||
const processoSelecionado = null;
|
const isLoadingItens = loadingPecas || loadingApontamentos || loadingComponentes;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
@@ -583,15 +546,12 @@ export const ApontamentoForm = () => {
|
|||||||
<Alert>
|
<Alert>
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{processoSelecionado.ordem === 1
|
{`Processo: ${processoSelecionado.ordem}. ${processoSelecionado.nome}. Selecione as peças ou componentes com saldo pendente para apontar.`}
|
||||||
? `Processo inicial: ${processoSelecionado.nome}. Todos os itens estão disponíveis.`
|
|
||||||
: `Processo ${processoSelecionado.ordem}: ${processoSelecionado.nome}. Apenas itens que passaram pelos processos anteriores estão disponíveis.`
|
|
||||||
}
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Seletor de itens otimizado com funcionalidade de lote - agora com scroll */}
|
{/* Seletor de itens otimizado com funcionalidade de lote */}
|
||||||
{formData.processo_id && (
|
{formData.processo_id && (
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
<SeletorItensOtimizado
|
<SeletorItensOtimizado
|
||||||
@@ -600,7 +560,7 @@ export const ApontamentoForm = () => {
|
|||||||
itemSelecionado={itemSelecionado}
|
itemSelecionado={itemSelecionado}
|
||||||
onItemSelect={handleItemSelect}
|
onItemSelect={handleItemSelect}
|
||||||
onBatchSelect={handleBatchSelect}
|
onBatchSelect={handleBatchSelect}
|
||||||
loading={loadingItens || isProcessingItems}
|
loading={isLoadingItens}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -660,7 +620,7 @@ export const ApontamentoForm = () => {
|
|||||||
</h4>
|
</h4>
|
||||||
{!itemSelecionado ? (
|
{!itemSelecionado ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Selecione um item para ver as informações ou use os checkboxes para registro em lote
|
Selecione um item para ver as informações ou use os botões para registro em lote
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
@@ -668,7 +628,7 @@ export const ApontamentoForm = () => {
|
|||||||
<div><strong>Marca:</strong> {itemSelecionado.marca}</div>
|
<div><strong>Marca:</strong> {itemSelecionado.marca}</div>
|
||||||
<div><strong>OF:</strong> {formData.of_number}</div>
|
<div><strong>OF:</strong> {formData.of_number}</div>
|
||||||
<div><strong>Fase:</strong> {formData.fase}</div>
|
<div><strong>Fase:</strong> {formData.fase}</div>
|
||||||
<div><strong>Processo:</strong> {processoSelecionado?.nome || 'N/A'}</div>
|
<div><strong>Processo:</strong> {processoSelecionado ? `${processoSelecionado.ordem}. ${processoSelecionado.nome}` : 'N/A'}</div>
|
||||||
<div><strong>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
|
<div><strong>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
|
||||||
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
|
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -676,7 +636,7 @@ export const ApontamentoForm = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Botões movidos para baixo do card de informações */}
|
{/* Botões de ação */}
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
{formData.of_number && formData.fase && formData.processo_id && (
|
{formData.of_number && formData.fase && formData.processo_id && (
|
||||||
<Button
|
<Button
|
||||||
@@ -690,7 +650,7 @@ export const ApontamentoForm = () => {
|
|||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving || !itemSelecionado || !formData.processo_id || loadingItens || isProcessingItems}
|
disabled={saving || !itemSelecionado || !formData.processo_id || isLoadingItens}
|
||||||
className="min-w-32"
|
className="min-w-32"
|
||||||
>
|
>
|
||||||
{saving ? 'Salvando...' : 'Registrar Apontamento'}
|
{saving ? 'Salvando...' : 'Registrar Apontamento'}
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ export const ApontamentosListOtimizado: React.FC = () => {
|
|||||||
.select(`
|
.select(`
|
||||||
of_number,
|
of_number,
|
||||||
quantidade_produzida,
|
quantidade_produzida,
|
||||||
peca:pecas(marca),
|
peca:pecas!apontamentos_producao_peca_id_fkey(marca),
|
||||||
componente:componentes_peca(marca_componente),
|
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente),
|
||||||
processo:processos_fabricacao(nome)
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome)
|
||||||
`)
|
`)
|
||||||
.eq('id', apontamentoId)
|
.eq('id', apontamentoId)
|
||||||
.single();
|
.single();
|
||||||
@@ -72,9 +72,10 @@ export const ApontamentosListOtimizado: React.FC = () => {
|
|||||||
await refetch();
|
await refetch();
|
||||||
console.log('Lista de apontamentos atualizada');
|
console.log('Lista de apontamentos atualizada');
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Erro completo ao reverter apontamento:', error);
|
console.error('Erro completo ao reverter apontamento:', error);
|
||||||
toast.error(`Erro ao reverter apontamento: ${error.message || 'Erro desconhecido'}`);
|
const msg = error instanceof Error ? error.message : 'Erro desconhecido';
|
||||||
|
toast.error(`Erro ao reverter apontamento: ${msg}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ interface CronogramaFormProps {
|
|||||||
cronograma?: CronogramaOf | null;
|
cronograma?: CronogramaOf | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
onSaveSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const processosDefault = [
|
const processosDefault = [
|
||||||
@@ -30,7 +31,19 @@ const processosDefault = [
|
|||||||
'Aceite/DB'
|
'Aceite/DB'
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onClose, isOpen }) => {
|
interface OFOption {
|
||||||
|
id: string;
|
||||||
|
num_of: string;
|
||||||
|
descritivo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GestorOption {
|
||||||
|
id: string;
|
||||||
|
full_name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onClose, isOpen, onSaveSuccess }) => {
|
||||||
const { saveCronograma, getCronogramaPorOf } = useCronogramaOperations();
|
const { saveCronograma, getCronogramaPorOf } = useCronogramaOperations();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
of_id: '',
|
of_id: '',
|
||||||
@@ -44,8 +57,8 @@ export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onCl
|
|||||||
})) as ProcessoCronograma[]
|
})) as ProcessoCronograma[]
|
||||||
});
|
});
|
||||||
|
|
||||||
const [ofs, setOfs] = useState<any[]>([]);
|
const [ofs, setOfs] = useState<OFOption[]>([]);
|
||||||
const [gestores, setGestores] = useState<any[]>([]);
|
const [gestores, setGestores] = useState<GestorOption[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -228,6 +241,9 @@ export const CronogramaForm: React.FC<CronogramaFormProps> = ({ cronograma, onCl
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
if (onSaveSuccess) {
|
||||||
|
onSaveSuccess();
|
||||||
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
|
import React, { useEffect, useCallback } from 'react';
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import { CronogramaOf } from '@/hooks/useCronogramas';
|
import { CronogramaOf } from '@/hooks/useCronogramas';
|
||||||
import { useBrandSettings } from '@/hooks/useBrandSettings';
|
import { useBrandSettings } from '@/hooks/useBrandSettings';
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
@@ -15,30 +14,49 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
|
|||||||
const { brandSettings } = useBrandSettings();
|
const { brandSettings } = useBrandSettings();
|
||||||
|
|
||||||
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => {
|
const calcularDiasCorridos = (dataInicio: string, dataFim: string) => {
|
||||||
return differenceInDays(parseISO(dataFim), parseISO(dataInicio)) + 1;
|
try {
|
||||||
|
const inc = parseISO(dataInicio);
|
||||||
|
const fim = parseISO(dataFim);
|
||||||
|
const diff = differenceInDays(fim, inc);
|
||||||
|
return diff >= 0 ? diff + 1 : 1;
|
||||||
|
} catch {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const gerarPDF = async () => {
|
const gerarPDF = useCallback(async () => {
|
||||||
const doc = new jsPDF('portrait', 'mm', 'a4');
|
// Formato Paisagem A4 (297mm x 210mm) - Amplo espaço para gráfico Gantt e tabela
|
||||||
|
const doc = new jsPDF('landscape', 'mm', 'a4');
|
||||||
|
|
||||||
// Configurações de cores (tons profissionais em cinza)
|
const pageWidth = doc.internal.pageSize.width; // 297mm
|
||||||
const corCinzaClaro = [200, 200, 200]; // Cinza claro para cabeçalho
|
const pageHeight = doc.internal.pageSize.height; // 210mm
|
||||||
const corCinzaMedio = [150, 150, 150]; // Cinza médio
|
const margin = 12;
|
||||||
const corCinzaEscuro = [80, 80, 80]; // Cinza escuro para texto
|
const usableWidth = pageWidth - (margin * 2); // 273mm
|
||||||
const corBranco = [255, 255, 255]; // Branco
|
|
||||||
|
|
||||||
const pageWidth = doc.internal.pageSize.width;
|
// Palette de Cores Executiva Modern (Otimizada para Impressora Monocromática)
|
||||||
const pageHeight = doc.internal.pageSize.height;
|
const cPrimary = [241, 245, 249]; // Slate 100 (Fundo do cabeçalho)
|
||||||
const margin = 15;
|
const cSecondary = [226, 232, 240]; // Slate 200
|
||||||
const usableWidth = pageWidth - (margin * 2);
|
const cAccent = [71, 85, 105]; // Slate 600 (Highlight)
|
||||||
let yPosition = margin;
|
const cTextDark = [15, 23, 42]; // Slate 900 (Texto principal)
|
||||||
|
const cTextMuted = [71, 85, 105]; // Slate 600 (Subtítulos)
|
||||||
|
const cBorder = [203, 213, 225]; // Slate 300 (Bordas)
|
||||||
|
const cBgCard = [255, 255, 255]; // Branco (Fundo de cards, economiza tinta)
|
||||||
|
const cWhite = [255, 255, 255];
|
||||||
|
|
||||||
// CABEÇALHO COMPACTO
|
// ----------------------------------------------------
|
||||||
// Fundo do cabeçalho - altura reduzida para 25px
|
// 1. CABEÇALHO EXECUTIVO (HEADER SLATE DARK BANNER)
|
||||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
// ----------------------------------------------------
|
||||||
doc.rect(0, 0, pageWidth, 25, 'F');
|
const headerHeight = 28;
|
||||||
|
doc.setFillColor(cPrimary[0], cPrimary[1], cPrimary[2]);
|
||||||
|
doc.rect(0, 0, pageWidth, headerHeight, 'F');
|
||||||
|
|
||||||
// Logo da empresa (tamanho reduzido)
|
// Accent line abaixo do cabeçalho
|
||||||
|
doc.setFillColor(cAccent[0], cAccent[1], cAccent[2]);
|
||||||
|
doc.rect(0, headerHeight, pageWidth, 1.5, 'F');
|
||||||
|
|
||||||
|
let headerTextX = margin;
|
||||||
|
|
||||||
|
// Logo da Empresa com proporção preservada (Sem distorção)
|
||||||
if (brandSettings.logo_url) {
|
if (brandSettings.logo_url) {
|
||||||
try {
|
try {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -49,237 +67,324 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
|
|||||||
img.src = brandSettings.logo_url!;
|
img.src = brandSettings.logo_url!;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logo menor - 20x15
|
// Calcular aspect ratio para não espremer a imagem
|
||||||
const logoWidth = 20;
|
const maxH = 16;
|
||||||
const logoHeight = 15;
|
const maxW = 45;
|
||||||
doc.addImage(img, 'PNG', margin, 5, logoWidth, logoHeight);
|
let imgW = maxH * (img.naturalWidth / img.naturalHeight);
|
||||||
|
let imgH = maxH;
|
||||||
|
if (imgW > maxW) {
|
||||||
|
imgW = maxW;
|
||||||
|
imgH = maxW * (img.naturalHeight / img.naturalWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoY = (headerHeight - imgH) / 2;
|
||||||
|
doc.addImage(img, 'PNG', margin, logoY, imgW, imgH);
|
||||||
|
headerTextX = margin + imgW + 8;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Erro ao carregar logo:', error);
|
console.log('Erro ao carregar logo no PDF:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nome da empresa e título
|
// Título da Empresa & Documento
|
||||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
|
||||||
doc.setFontSize(14);
|
doc.setFontSize(15);
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.text(brandSettings.company_name, brandSettings.logo_url ? margin + 25 : margin, 12);
|
doc.text(brandSettings.company_name || 'TrackSteel', headerTextX, 12);
|
||||||
|
|
||||||
doc.setFontSize(10);
|
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.text('CRONOGRAMA DE PRODUÇÃO', brandSettings.logo_url ? margin + 25 : margin, 18);
|
|
||||||
|
|
||||||
yPosition = 35;
|
|
||||||
|
|
||||||
// TÍTULO DO CRONOGRAMA
|
|
||||||
doc.setFillColor(240, 240, 240);
|
|
||||||
doc.rect(margin, yPosition - 3, usableWidth, 12, 'F');
|
|
||||||
|
|
||||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
|
||||||
doc.setFontSize(12);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
const titulo = `OF: ${cronograma.ordem_fabricacao?.num_of} - Revisão: ${cronograma.revisao}`;
|
|
||||||
doc.text(titulo, margin + 3, yPosition + 3);
|
|
||||||
|
|
||||||
doc.setFontSize(9);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.text(`Gestor: ${cronograma.gestor_profile?.full_name || 'N/A'}`, margin + 3, yPosition + 8);
|
|
||||||
|
|
||||||
yPosition += 18;
|
|
||||||
|
|
||||||
// RESUMO EXECUTIVO COMPACTO
|
|
||||||
const todasAsDatas = cronograma.processos.flatMap(p => [p.data_inicio, p.data_fim]);
|
|
||||||
const dataInicioTotal = new Date(Math.min(...todasAsDatas.map(d => new Date(d).getTime())));
|
|
||||||
const dataFimTotal = new Date(Math.max(...todasAsDatas.map(d => new Date(d).getTime())));
|
|
||||||
const duracaoTotal = differenceInDays(dataFimTotal, dataInicioTotal) + 1;
|
|
||||||
|
|
||||||
// Boxes do resumo em linha
|
|
||||||
const boxWidth = (usableWidth) / 3;
|
|
||||||
|
|
||||||
doc.setFillColor(corBranco[0], corBranco[1], corBranco[2]);
|
|
||||||
doc.setDrawColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
|
|
||||||
|
|
||||||
// Box 1
|
|
||||||
doc.rect(margin, yPosition, boxWidth - 2, 15, 'FD');
|
|
||||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text(`${cronograma.processos.length} Processos`, margin + 3, yPosition + 6);
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.text('Total', margin + 3, yPosition + 11);
|
|
||||||
|
|
||||||
// Box 2
|
|
||||||
doc.rect(margin + boxWidth, yPosition, boxWidth - 2, 15, 'FD');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text(`${duracaoTotal} dias`, margin + boxWidth + 3, yPosition + 6);
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.text('Duração', margin + boxWidth + 3, yPosition + 11);
|
|
||||||
|
|
||||||
// Box 3
|
|
||||||
doc.rect(margin + boxWidth * 2, yPosition, boxWidth - 2, 15, 'FD');
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text(`${format(dataInicioTotal, 'dd/MM', { locale: ptBR })} - ${format(dataFimTotal, 'dd/MM', { locale: ptBR })}`, margin + boxWidth * 2 + 3, yPosition + 6);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.text('Período', margin + boxWidth * 2 + 3, yPosition + 11);
|
|
||||||
|
|
||||||
yPosition += 22;
|
|
||||||
|
|
||||||
// TABELA COMPACTA
|
|
||||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
|
||||||
doc.rect(margin, yPosition, usableWidth, 8, 'F');
|
|
||||||
|
|
||||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('CRONOGRAMA DETALHADO', margin + 3, yPosition + 5);
|
|
||||||
|
|
||||||
yPosition += 10;
|
|
||||||
|
|
||||||
// Cabeçalho da tabela - altura reduzida
|
|
||||||
const colWidths = [usableWidth * 0.4, usableWidth * 0.2, usableWidth * 0.2, usableWidth * 0.2];
|
|
||||||
const headers = ['Processo', 'Início', 'Fim', 'Duração'];
|
|
||||||
|
|
||||||
doc.setFillColor(245, 245, 245);
|
|
||||||
doc.rect(margin, yPosition, usableWidth, 7, 'F');
|
|
||||||
|
|
||||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
|
||||||
doc.setFontSize(9);
|
doc.setFontSize(9);
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('RELATÓRIO EXECUTIVO DE CRONOGRAMA DE PRODUÇÃO', headerTextX, 19);
|
||||||
|
|
||||||
let xPosition = margin;
|
// Badges no canto direito do cabeçalho
|
||||||
headers.forEach((header, index) => {
|
const badgeY = 8;
|
||||||
doc.text(header, xPosition + 2, yPosition + 5);
|
const badgeRight = pageWidth - margin;
|
||||||
xPosition += colWidths[index];
|
|
||||||
});
|
|
||||||
|
|
||||||
yPosition += 7;
|
// Pill de OF
|
||||||
|
const ofNum = cronograma.ordem_fabricacao?.num_of || 'N/A';
|
||||||
// Linhas da tabela - altura reduzida em 40% (de 12 para 7.2)
|
doc.setFillColor(cSecondary[0], cSecondary[1], cSecondary[2]);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||||
|
doc.roundedRect(badgeRight - 65, badgeY, 35, 12, 2, 2, 'FD');
|
||||||
|
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
|
||||||
doc.setFontSize(8);
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`OF: ${ofNum}`, badgeRight - 47.5, badgeY + 7.5, { align: 'center' });
|
||||||
|
|
||||||
cronograma.processos
|
// Pill de Revisão
|
||||||
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
|
doc.setFillColor(cBgCard[0], cBgCard[1], cBgCard[2]);
|
||||||
.forEach((processo, index) => {
|
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||||
// Alternância de cores
|
doc.roundedRect(badgeRight - 27, badgeY, 27, 12, 2, 2, 'FD');
|
||||||
if (index % 2 === 0) {
|
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
|
||||||
doc.setFillColor(250, 250, 250);
|
doc.text(`REV: ${cronograma.revisao || 1}`, badgeRight - 13.5, badgeY + 7.5, { align: 'center' });
|
||||||
doc.rect(margin, yPosition, usableWidth, 7, 'F');
|
|
||||||
|
let yPos = headerHeight + 8;
|
||||||
|
|
||||||
|
// ----------------------------------------------------
|
||||||
|
// 2. CARDS DE RESUMO / KPIS
|
||||||
|
// ----------------------------------------------------
|
||||||
|
const processos = (cronograma.processos || []).sort((a, b) => (a.ordem || 0) - (b.ordem || 0));
|
||||||
|
|
||||||
|
// Cálculo das datas gerais
|
||||||
|
let dataInicioTotal = new Date();
|
||||||
|
let dataFimTotal = new Date();
|
||||||
|
let duracaoTotal = 0;
|
||||||
|
|
||||||
|
if (processos.length > 0) {
|
||||||
|
const datasInicio = processos.map(p => parseISO(p.data_inicio).getTime()).filter(t => !isNaN(t));
|
||||||
|
const datasFim = processos.map(p => parseISO(p.data_fim).getTime()).filter(t => !isNaN(t));
|
||||||
|
|
||||||
|
if (datasInicio.length > 0 && datasFim.length > 0) {
|
||||||
|
dataInicioTotal = new Date(Math.min(...datasInicio));
|
||||||
|
dataFimTotal = new Date(Math.max(...datasFim));
|
||||||
|
duracaoTotal = differenceInDays(dataFimTotal, dataInicioTotal) + 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
xPosition = margin;
|
const cardGap = 5;
|
||||||
const rowData = [
|
const cardWidth = (usableWidth - (cardGap * 3)) / 4;
|
||||||
processo.nome_processo.length > 25 ? processo.nome_processo.substring(0, 25) + '...' : processo.nome_processo,
|
const cardHeight = 18;
|
||||||
format(parseISO(processo.data_inicio), 'dd/MM', { locale: ptBR }),
|
|
||||||
format(parseISO(processo.data_fim), 'dd/MM', { locale: ptBR }),
|
const cardsData = [
|
||||||
`${calcularDiasCorridos(processo.data_inicio, processo.data_fim)}d`
|
{
|
||||||
|
title: 'ESTRUTURA / OF',
|
||||||
|
value: `${ofNum} - ${cronograma.ordem_fabricacao?.descritivo || 'Sem descrição'}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'GESTOR / RESPONSÁVEL',
|
||||||
|
value: cronograma.gestor_profile?.full_name || 'Não atribuído'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'DURAÇÃO & PROCESSO',
|
||||||
|
value: `${duracaoTotal} Dias Corridos (${processos.length} etapas)`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'PERÍODO PREVISTO',
|
||||||
|
value: `${format(dataInicioTotal, 'dd/MM/yyyy')} a ${format(dataFimTotal, 'dd/MM/yyyy')}`
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
cardsData.forEach((card, idx) => {
|
||||||
rowData.forEach((data, colIndex) => {
|
const cardX = margin + idx * (cardWidth + cardGap);
|
||||||
doc.text(data, xPosition + 2, yPosition + 5);
|
|
||||||
xPosition += colWidths[colIndex];
|
|
||||||
});
|
|
||||||
|
|
||||||
yPosition += 7;
|
// Card Container
|
||||||
});
|
doc.setFillColor(cBgCard[0], cBgCard[1], cBgCard[2]);
|
||||||
|
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||||
|
doc.roundedRect(cardX, yPos, cardWidth, cardHeight, 1.5, 1.5, 'FD');
|
||||||
|
|
||||||
yPosition += 10;
|
// Top Accent Line
|
||||||
|
doc.setFillColor(cAccent[0], cAccent[1], cAccent[2]);
|
||||||
|
doc.rect(cardX, yPos, cardWidth, 1, 'F');
|
||||||
|
|
||||||
// GRÁFICO DE GANTT VISUAL MELHORADO
|
// Card Title
|
||||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
|
||||||
doc.rect(margin, yPosition, usableWidth, 8, 'F');
|
|
||||||
|
|
||||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('LINHA DO TEMPO VISUAL', margin + 3, yPosition + 5);
|
|
||||||
|
|
||||||
yPosition += 12;
|
|
||||||
|
|
||||||
// Escala de tempo
|
|
||||||
doc.setTextColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.text(format(dataInicioTotal, 'dd/MM/yy', { locale: ptBR }), margin, yPosition - 2);
|
|
||||||
doc.text(format(dataFimTotal, 'dd/MM/yy', { locale: ptBR }), margin + usableWidth - 20, yPosition - 2);
|
|
||||||
doc.text(`${duracaoTotal} dias`, margin + usableWidth/2 - 10, yPosition - 2);
|
|
||||||
|
|
||||||
// Linha de base
|
|
||||||
doc.setDrawColor(corCinzaMedio[0], corCinzaMedio[1], corCinzaMedio[2]);
|
|
||||||
doc.line(margin, yPosition, margin + usableWidth, yPosition);
|
|
||||||
|
|
||||||
yPosition += 3;
|
|
||||||
|
|
||||||
// Barras dos processos - área reservada para nomes maior
|
|
||||||
const cores = [
|
|
||||||
[52, 152, 219], // Azul
|
|
||||||
[46, 204, 113], // Verde
|
|
||||||
[241, 196, 15], // Amarelo
|
|
||||||
[155, 89, 182], // Roxo
|
|
||||||
[231, 76, 60], // Vermelho
|
|
||||||
[230, 126, 34], // Laranja
|
|
||||||
[26, 188, 156], // Turquesa
|
|
||||||
[127, 140, 141] // Cinza
|
|
||||||
];
|
|
||||||
|
|
||||||
const nomeAreaWidth = 60; // Área reservada para nomes dos processos
|
|
||||||
const graficoWidth = usableWidth - nomeAreaWidth - 5;
|
|
||||||
|
|
||||||
cronograma.processos
|
|
||||||
.sort((a, b) => (a.ordem || 0) - (b.ordem || 0))
|
|
||||||
.forEach((processo, index) => {
|
|
||||||
const diasDoInicio = differenceInDays(parseISO(processo.data_inicio), dataInicioTotal);
|
|
||||||
const duracaoProcesso = calcularDiasCorridos(processo.data_inicio, processo.data_fim);
|
|
||||||
|
|
||||||
const barraInicio = margin + nomeAreaWidth + (diasDoInicio / duracaoTotal) * graficoWidth;
|
|
||||||
const barraLargura = Math.max(2, (duracaoProcesso / duracaoTotal) * graficoWidth);
|
|
||||||
|
|
||||||
// Nome do processo na área reservada
|
|
||||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
|
||||||
doc.setFontSize(7);
|
doc.setFontSize(7);
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
const nomeProcesso = processo.nome_processo.length > 20 ?
|
|
||||||
processo.nome_processo.substring(0, 20) + '...' :
|
|
||||||
processo.nome_processo;
|
|
||||||
doc.text(nomeProcesso, margin, yPosition + 2);
|
|
||||||
|
|
||||||
// Barra colorida
|
|
||||||
const cor = cores[index % cores.length];
|
|
||||||
doc.setFillColor(cor[0], cor[1], cor[2]);
|
|
||||||
doc.rect(barraInicio, yPosition - 1, barraLargura, 6, 'F');
|
|
||||||
|
|
||||||
// Duração na barra (se houver espaço)
|
|
||||||
if (barraLargura > 8) {
|
|
||||||
doc.setTextColor(255, 255, 255);
|
|
||||||
doc.setFontSize(6);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.text(`${duracaoProcesso}d`, barraInicio + barraLargura/2 - 2, yPosition + 2);
|
doc.text(card.title, cardX + 3, yPos + 5.5);
|
||||||
|
|
||||||
|
// Card Value
|
||||||
|
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
|
||||||
|
doc.setFontSize(8.5);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
const textTruncated = card.value.length > 32 ? card.value.substring(0, 32) + '...' : card.value;
|
||||||
|
doc.text(textTruncated, cardX + 3, yPos + 12.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
yPos += cardHeight + 8;
|
||||||
|
|
||||||
|
// ----------------------------------------------------
|
||||||
|
// 3. ESTRUTURA PRINCIPAL: TABELA (ESQUERDA) + GANTT (DIREITA)
|
||||||
|
// ----------------------------------------------------
|
||||||
|
const tableWidth = 110; // Tabela ocupa 110mm
|
||||||
|
const ganttWidth = usableWidth - tableWidth - 6; // Gantt ocupa o restante (~157mm)
|
||||||
|
const ganttX = margin + tableWidth + 6;
|
||||||
|
|
||||||
|
// TÍTULOS DAS SEÇÕES
|
||||||
|
doc.setFillColor(cSecondary[0], cSecondary[1], cSecondary[2]);
|
||||||
|
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||||
|
doc.roundedRect(margin, yPos, tableWidth, 7, 1, 1, 'FD');
|
||||||
|
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
|
||||||
|
doc.setFontSize(8.5);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('ETAPAS E CRONOGRAMA DETALHADO', margin + 4, yPos + 4.8);
|
||||||
|
|
||||||
|
doc.setFillColor(cSecondary[0], cSecondary[1], cSecondary[2]);
|
||||||
|
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||||
|
doc.roundedRect(ganttX, yPos, ganttWidth, 7, 1, 1, 'FD');
|
||||||
|
doc.text('VISUALIZAÇÃO DE LINHA DO TEMPO (GANTT)', ganttX + 4, yPos + 4.8);
|
||||||
|
|
||||||
|
yPos += 9;
|
||||||
|
|
||||||
|
// --- TABELA DE PROCESSOS ---
|
||||||
|
const colWidths = [10, 48, 20, 20, 12]; // Total: 110mm
|
||||||
|
const headers = ['#', 'Processo / Etapa', 'Início', 'Fim', 'Dias'];
|
||||||
|
|
||||||
|
doc.setFillColor(cBgCard[0], cBgCard[1], cBgCard[2]);
|
||||||
|
doc.rect(margin, yPos, tableWidth, 6, 'F');
|
||||||
|
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||||
|
doc.line(margin, yPos + 6, margin + tableWidth, yPos + 6);
|
||||||
|
|
||||||
|
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
|
||||||
|
let colX = margin;
|
||||||
|
headers.forEach((h, i) => {
|
||||||
|
const align = i >= 2 ? 'center' : 'left';
|
||||||
|
const textPosX = align === 'center' ? colX + colWidths[i] / 2 : colX + 2;
|
||||||
|
doc.text(h, textPosX, yPos + 4.2, { align });
|
||||||
|
colX += colWidths[i];
|
||||||
|
});
|
||||||
|
|
||||||
|
let tableY = yPos + 6;
|
||||||
|
const rowHeight = 7.5;
|
||||||
|
|
||||||
|
// --- GRÁFICO GANTT (CABEÇALHO DE DATAS) ---
|
||||||
|
doc.setFillColor(cBgCard[0], cBgCard[1], cBgCard[2]);
|
||||||
|
doc.rect(ganttX, yPos, ganttWidth, 6, 'F');
|
||||||
|
doc.line(ganttX, yPos + 6, ganttX + ganttWidth, yPos + 6);
|
||||||
|
|
||||||
|
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
|
||||||
|
doc.text(format(dataInicioTotal, 'dd/MM'), ganttX + 2, yPos + 4.2);
|
||||||
|
doc.text(format(dataFimTotal, 'dd/MM'), ganttX + ganttWidth - 2, yPos + 4.2, { align: 'right' });
|
||||||
|
doc.text(`Total: ${duracaoTotal}d`, ganttX + ganttWidth / 2, yPos + 4.2, { align: 'center' });
|
||||||
|
|
||||||
|
// Paleta Elegante para as Barras de Gantt (Tons de Cinza/Escuros para impressão monocromática)
|
||||||
|
const barColors = [
|
||||||
|
[71, 85, 105], // Slate 600
|
||||||
|
[51, 65, 85], // Slate 700
|
||||||
|
[100, 116, 139], // Slate 500
|
||||||
|
[15, 23, 42] // Slate 900
|
||||||
|
];
|
||||||
|
|
||||||
|
// RENDERIZAR LINHAS DA TABELA E BARRAS GANTT
|
||||||
|
processos.forEach((proc, idx) => {
|
||||||
|
// Alternância de cor da linha na tabela
|
||||||
|
if (idx % 2 === 0) {
|
||||||
|
doc.setFillColor(255, 255, 255);
|
||||||
|
} else {
|
||||||
|
doc.setFillColor(248, 250, 252);
|
||||||
|
}
|
||||||
|
doc.rect(margin, tableY, tableWidth, rowHeight, 'F');
|
||||||
|
|
||||||
|
// Borda inferior da linha
|
||||||
|
doc.setDrawColor(241, 245, 249);
|
||||||
|
doc.line(margin, tableY + rowHeight, margin + tableWidth, tableY + rowHeight);
|
||||||
|
|
||||||
|
// Dados da tabela
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
|
||||||
|
|
||||||
|
let cX = margin;
|
||||||
|
|
||||||
|
// Index #
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`${idx + 1}`, cX + colWidths[0] / 2, tableY + 5, { align: 'center' });
|
||||||
|
cX += colWidths[0];
|
||||||
|
|
||||||
|
// Nome do Processo
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
const nomeProc = proc.nome_processo.length > 24 ? proc.nome_processo.substring(0, 24) + '...' : proc.nome_processo;
|
||||||
|
doc.text(nomeProc, cX + 2, tableY + 5);
|
||||||
|
cX += colWidths[1];
|
||||||
|
|
||||||
|
// Data Início
|
||||||
|
const dIncStr = format(parseISO(proc.data_inicio), 'dd/MM/yy');
|
||||||
|
doc.text(dIncStr, cX + colWidths[2] / 2, tableY + 5, { align: 'center' });
|
||||||
|
cX += colWidths[2];
|
||||||
|
|
||||||
|
// Data Fim
|
||||||
|
const dFimStr = format(parseISO(proc.data_fim), 'dd/MM/yy');
|
||||||
|
doc.text(dFimStr, cX + colWidths[3] / 2, tableY + 5, { align: 'center' });
|
||||||
|
cX += colWidths[3];
|
||||||
|
|
||||||
|
// Duração em Dias
|
||||||
|
const diasProc = calcularDiasCorridos(proc.data_inicio, proc.data_fim);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`${diasProc}d`, cX + colWidths[4] / 2, tableY + 5, { align: 'center' });
|
||||||
|
|
||||||
|
// --- LINHA E BARRA DO GANTT ---
|
||||||
|
// Fundo e linha de grade do Gantt
|
||||||
|
if (idx % 2 === 0) {
|
||||||
|
doc.setFillColor(255, 255, 255);
|
||||||
|
} else {
|
||||||
|
doc.setFillColor(248, 250, 252);
|
||||||
|
}
|
||||||
|
doc.rect(ganttX, tableY, ganttWidth, rowHeight, 'F');
|
||||||
|
doc.setDrawColor(241, 245, 249);
|
||||||
|
doc.line(ganttX, tableY + rowHeight, ganttX + ganttWidth, tableY + rowHeight);
|
||||||
|
|
||||||
|
// Calcular posição da barra Gantt proporcional
|
||||||
|
const procStart = parseISO(proc.data_inicio).getTime();
|
||||||
|
const offsetDays = Math.max(0, differenceInDays(new Date(procStart), dataInicioTotal));
|
||||||
|
|
||||||
|
const pxPerDay = ganttWidth / (duracaoTotal || 1);
|
||||||
|
const barX = ganttX + (offsetDays * pxPerDay);
|
||||||
|
const barW = Math.max(4, diasProc * pxPerDay);
|
||||||
|
|
||||||
|
// Desenhar Barra Arredondada Modern
|
||||||
|
const color = barColors[idx % barColors.length];
|
||||||
|
doc.setFillColor(color[0], color[1], color[2]);
|
||||||
|
doc.roundedRect(barX, tableY + 1.5, barW, rowHeight - 3, 1.2, 1.2, 'F');
|
||||||
|
|
||||||
|
// Texto dentro ou ao lado da barra
|
||||||
|
doc.setTextColor(cWhite[0], cWhite[1], cWhite[2]);
|
||||||
|
doc.setFontSize(6.5);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
|
||||||
|
if (barW >= 15) {
|
||||||
|
doc.text(`${diasProc}d`, barX + (barW / 2), tableY + 5, { align: 'center' });
|
||||||
|
} else {
|
||||||
|
// Se a barra for pequena, desenha a tag fora
|
||||||
|
doc.setTextColor(cTextDark[0], cTextDark[1], cTextDark[2]);
|
||||||
|
doc.text(`${diasProc}d`, barX + barW + 2, tableY + 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
yPosition += 8;
|
tableY += rowHeight;
|
||||||
});
|
});
|
||||||
|
|
||||||
// RODAPÉ COMPACTO
|
// Borda ao redor das caixas
|
||||||
doc.setFillColor(corCinzaClaro[0], corCinzaClaro[1], corCinzaClaro[2]);
|
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||||
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F');
|
doc.rect(margin, yPos + 6, tableWidth, (processos.length + 1) * rowHeight, 'S');
|
||||||
|
doc.rect(ganttX, yPos + 6, ganttWidth, (processos.length + 1) * rowHeight, 'S');
|
||||||
|
|
||||||
doc.setTextColor(corCinzaEscuro[0], corCinzaEscuro[1], corCinzaEscuro[2]);
|
// ----------------------------------------------------
|
||||||
doc.setFontSize(7);
|
// 4. RODAPÉ EXECUTIVO (FOOTER)
|
||||||
|
// ----------------------------------------------------
|
||||||
|
const footerY = pageHeight - 10;
|
||||||
|
|
||||||
|
doc.setDrawColor(cBorder[0], cBorder[1], cBorder[2]);
|
||||||
|
doc.line(margin, footerY - 3, pageWidth - margin, footerY - 3);
|
||||||
|
|
||||||
|
doc.setTextColor(cTextMuted[0], cTextMuted[1], cTextMuted[2]);
|
||||||
|
doc.setFontSize(7.5);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.text(`${brandSettings.company_name} - Sistema de Gestão`, margin, pageHeight - 8);
|
|
||||||
doc.text(`${format(new Date(), 'dd/MM/yyyy HH:mm')}`, pageWidth - 35, pageHeight - 8);
|
|
||||||
|
|
||||||
// Download do PDF
|
doc.text(
|
||||||
const nomeArquivo = `cronograma_${cronograma.ordem_fabricacao?.num_of}_rev${cronograma.revisao}_${format(new Date(), 'ddMMyyyy')}.pdf`;
|
`${brandSettings.company_name || 'TrackSteel'} — Sistema Integrado de Gestão Estrutural`,
|
||||||
|
margin,
|
||||||
|
footerY + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const nowFormatted = format(new Date(), 'dd/MM/yyyy HH:mm', { locale: ptBR });
|
||||||
|
doc.text(
|
||||||
|
`Documento Gerado em ${nowFormatted} | Página 1 de 1`,
|
||||||
|
pageWidth - margin,
|
||||||
|
footerY + 1,
|
||||||
|
{ align: 'right' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Download automático do PDF
|
||||||
|
const safeOF = (cronograma.ordem_fabricacao?.num_of || 'OF').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
|
const nomeArquivo = `cronograma_${safeOF}_rev${cronograma.revisao || 1}_${format(new Date(), 'ddMMyyyy')}.pdf`;
|
||||||
doc.save(nomeArquivo);
|
doc.save(nomeArquivo);
|
||||||
|
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
};
|
}, [cronograma, onComplete, brandSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -287,7 +392,7 @@ export const CronogramaPDF: React.FC<CronogramaPDFProps> = ({ cronograma, onComp
|
|||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [cronograma, onComplete, brandSettings]);
|
}, [gerarPDF]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
import React, { useMemo } from 'react';
|
import {
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
ResponsiveContainer,
|
||||||
import { format, parseISO, eachDayOfInterval } from 'date-fns';
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip
|
||||||
|
} from 'recharts';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
import { ptBR } from 'date-fns/locale';
|
import { ptBR } from 'date-fns/locale';
|
||||||
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
|
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Scale, Percent, FilterX } from 'lucide-react';
|
||||||
|
|
||||||
interface GraficoMestreProps {
|
interface GraficoMestreProps {
|
||||||
processos: DashboardProcesso[];
|
processos: DashboardProcesso[];
|
||||||
@@ -11,126 +20,324 @@ interface GraficoMestreProps {
|
|||||||
processoSelecionado?: string | null;
|
processoSelecionado?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ordem sequencial padrão de produção para ordenação linear
|
||||||
|
const ORDEM_PROCESSOS_LINEAR = [
|
||||||
|
'detalhamento',
|
||||||
|
'corte',
|
||||||
|
'solda',
|
||||||
|
'pintura/galv',
|
||||||
|
'pintura',
|
||||||
|
'galvanizacao',
|
||||||
|
'expedicao',
|
||||||
|
'expedição',
|
||||||
|
'montagem',
|
||||||
|
'aceite/db',
|
||||||
|
'aceite',
|
||||||
|
'concluido',
|
||||||
|
'concluído'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Paleta de cores vibrantes e distintas para cada processo
|
||||||
|
const CORES_PROCESSOS: Record<string, string> = {
|
||||||
|
'detalhamento': '#a855f7', // Roxo / Purple
|
||||||
|
'corte': '#3b82f6', // Azul Royal
|
||||||
|
'solda': '#f97316', // Laranja Vibrante
|
||||||
|
'pintura/galv': '#ec4899', // Rosa / Magenta
|
||||||
|
'pintura': '#ec4899', // Rosa
|
||||||
|
'galvanizacao': '#ec4899', // Rosa
|
||||||
|
'expedicao': '#06b6d4', // Ciano
|
||||||
|
'expedição': '#06b6d4', // Ciano
|
||||||
|
'montagem': '#14b8a6', // Verde Água / Teal
|
||||||
|
'aceite/db': '#6366f1', // Índigo
|
||||||
|
'aceite': '#6366f1', // Índigo
|
||||||
|
'concluido': '#10b981', // Verde Esmeralda
|
||||||
|
'concluído': '#10b981' // Verde Esmeralda
|
||||||
|
};
|
||||||
|
|
||||||
|
// Paleta de fallback se houver processo fora do padrão
|
||||||
|
const CORES_FALLBACK = [
|
||||||
|
'#a855f7', '#3b82f6', '#f97316', '#ec4899',
|
||||||
|
'#06b6d4', '#14b8a6', '#6366f1', '#10b981', '#eab308'
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CustomTooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
dataKey: string;
|
||||||
|
}>;
|
||||||
|
label?: string;
|
||||||
|
modoExibicao: 'kg' | 'percent';
|
||||||
|
selectedProcesso: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente Tooltip declarado fora da renderização principal para evitar recriação no render
|
||||||
|
const CustomTooltip: React.FC<CustomTooltipProps> = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
modoExibicao,
|
||||||
|
selectedProcesso
|
||||||
|
}) => {
|
||||||
|
if (!active || !payload || !payload.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-popover/95 backdrop-blur-md border border-border shadow-2xl rounded-xl p-3 text-popover-foreground min-w-[200px] z-50">
|
||||||
|
<div className="flex items-center justify-between border-b border-border/50 pb-2 mb-2">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">Data</span>
|
||||||
|
<span className="text-xs font-bold bg-primary/10 text-primary px-2 py-0.5 rounded-md">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{payload.map((entry, index) => {
|
||||||
|
const isHighlight = !selectedProcesso || selectedProcesso === entry.name;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`item-${index}`}
|
||||||
|
className={`flex items-center justify-between text-xs transition-opacity ${
|
||||||
|
isHighlight ? 'opacity-100 font-medium' : 'opacity-40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-sm"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-[130px]">{entry.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-semibold ml-2">
|
||||||
|
{modoExibicao === 'kg'
|
||||||
|
? `${entry.value.toLocaleString('pt-BR')} kg`
|
||||||
|
: `${entry.value.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const GraficoMestre: React.FC<GraficoMestreProps> = ({
|
export const GraficoMestre: React.FC<GraficoMestreProps> = ({
|
||||||
processos,
|
processos,
|
||||||
onProcessoClick,
|
onProcessoClick,
|
||||||
processoSelecionado
|
processoSelecionado: processoSelecionadoProp
|
||||||
}) => {
|
}) => {
|
||||||
// Processar dados dos gráficos individuais para criar um gráfico sobreposto
|
const [modoExibicao, setModoExibicao] = useState<'kg' | 'percent'>('kg');
|
||||||
const dadosGraficoSobreposto = useMemo(() => {
|
const [selectedProcessoInternal, setSelectedProcessoInternal] = useState<string | null>(null);
|
||||||
if (!processos || processos.length === 0) return [];
|
|
||||||
|
const selectedProcesso = processoSelecionadoProp !== undefined ? processoSelecionadoProp : selectedProcessoInternal;
|
||||||
|
|
||||||
|
const handleSelectProcesso = (nome: string) => {
|
||||||
|
const nextSelected = selectedProcesso === nome ? null : nome;
|
||||||
|
setSelectedProcessoInternal(nextSelected);
|
||||||
|
if (onProcessoClick) {
|
||||||
|
onProcessoClick(nextSelected || '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Ordenar processos na sequência linear real da produção
|
||||||
|
const processosOrdenados = useMemo(() => {
|
||||||
|
if (!processos) return [];
|
||||||
|
|
||||||
|
return [...processos].sort((a, b) => {
|
||||||
|
const nomeA = a.nome.toLowerCase().trim();
|
||||||
|
const nomeB = b.nome.toLowerCase().trim();
|
||||||
|
|
||||||
|
const indexA = ORDEM_PROCESSOS_LINEAR.findIndex(p => nomeA.includes(p));
|
||||||
|
const indexB = ORDEM_PROCESSOS_LINEAR.findIndex(p => nomeB.includes(p));
|
||||||
|
|
||||||
|
const posA = indexA !== -1 ? indexA : 99;
|
||||||
|
const posB = indexB !== -1 ? indexB : 99;
|
||||||
|
|
||||||
|
return posA - posB;
|
||||||
|
});
|
||||||
|
}, [processos]);
|
||||||
|
|
||||||
|
// Função utilitária para pegar a cor de um processo
|
||||||
|
const getCorProcesso = (nomeProcesso: string, index: number) => {
|
||||||
|
const nomeLower = nomeProcesso.toLowerCase().trim();
|
||||||
|
for (const [key, color] of Object.entries(CORES_PROCESSOS)) {
|
||||||
|
if (nomeLower.includes(key)) return color;
|
||||||
|
}
|
||||||
|
return CORES_FALLBACK[index % CORES_FALLBACK.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Processar dados agregados por data
|
||||||
|
const dadosGrafico = useMemo(() => {
|
||||||
|
if (!processosOrdenados || processosOrdenados.length === 0) return [];
|
||||||
|
|
||||||
// Coletar todas as datas dos gráficos individuais
|
|
||||||
const todasAsDatas = new Set<string>();
|
const todasAsDatas = new Set<string>();
|
||||||
processos.forEach(processo => {
|
processosOrdenados.forEach(processo => {
|
||||||
processo.dadosGrafico.forEach(ponto => {
|
processo.dadosGrafico.forEach(ponto => {
|
||||||
todasAsDatas.add(ponto.data);
|
todasAsDatas.add(ponto.data);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ordenar as datas
|
|
||||||
const datasOrdenadas = Array.from(todasAsDatas).sort();
|
const datasOrdenadas = Array.from(todasAsDatas).sort();
|
||||||
|
const ultimosValoresKg: Record<string, number> = {};
|
||||||
|
|
||||||
// Construir dados do gráfico sobreposto
|
|
||||||
return datasOrdenadas.map(data => {
|
return datasOrdenadas.map(data => {
|
||||||
const pontoGrafico: any = {
|
const dataFormatada = format(parseISO(data), 'dd/MM', { locale: ptBR });
|
||||||
data: format(parseISO(data), 'dd/MM', { locale: ptBR }),
|
const pontoGrafico: Record<string, string | number> = {
|
||||||
|
data: dataFormatada,
|
||||||
dataCompleta: data
|
dataCompleta: data
|
||||||
};
|
};
|
||||||
|
|
||||||
// Para cada processo, buscar o valor realizado na data
|
processosOrdenados.forEach(processo => {
|
||||||
processos.forEach(processo => {
|
const pontoProcesso = processo.dadosGrafico.find(p => p.data === data);
|
||||||
const pontoProcesso = processo.dadosGrafico.find(ponto => ponto.data === data);
|
|
||||||
// Converter para toneladas (dividir por 1000)
|
let valorKg: number;
|
||||||
pontoGrafico[processo.nome] = pontoProcesso ? Math.round(pontoProcesso.realizado / 1000 * 100) / 100 : 0;
|
if (pontoProcesso && pontoProcesso.realizado > 0) {
|
||||||
|
valorKg = pontoProcesso.realizado;
|
||||||
|
ultimosValoresKg[processo.nome] = valorKg;
|
||||||
|
} else {
|
||||||
|
// Se não houver novo ponto ou for 0, mantém o acumulado anterior para a linha NUNCA cair
|
||||||
|
valorKg = ultimosValoresKg[processo.nome] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modoExibicao === 'percent') {
|
||||||
|
const pesoTotal = processo.pesoTotal > 0 ? processo.pesoTotal : 1;
|
||||||
|
const percentual = Math.min(100, (valorKg / pesoTotal) * 100);
|
||||||
|
pontoGrafico[processo.nome] = Math.round(percentual * 10) / 10;
|
||||||
|
} else {
|
||||||
|
pontoGrafico[processo.nome] = Math.round(valorKg * 10) / 10;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return pontoGrafico;
|
return pontoGrafico;
|
||||||
});
|
});
|
||||||
}, [processos]);
|
}, [processosOrdenados, modoExibicao]);
|
||||||
|
|
||||||
// Obter cores dos processos baseado no status
|
|
||||||
const obterCorProcesso = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'verde':
|
|
||||||
return '#10b981';
|
|
||||||
case 'amarelo':
|
|
||||||
return '#f59e0b';
|
|
||||||
case 'vermelho':
|
|
||||||
return '#ef4444';
|
|
||||||
case 'azul':
|
|
||||||
return '#3b82f6';
|
|
||||||
default:
|
|
||||||
return '#8884d8';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProcessoClick = (processoNome: string) => {
|
|
||||||
if (onProcessoClick) {
|
|
||||||
onProcessoClick(processoNome);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTooltipValue = (value: number, name: string) => [
|
|
||||||
`${value.toFixed(2)} t`,
|
|
||||||
name
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-96">
|
<div className="space-y-4 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
{/* Barra Superior de Controles */}
|
||||||
<LineChart
|
<div className="flex flex-wrap items-center justify-between gap-2 bg-muted/40 p-2.5 rounded-lg border border-border/50">
|
||||||
data={dadosGraficoSobreposto}
|
<div className="flex items-center gap-2">
|
||||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
<span className="text-xs font-medium text-muted-foreground hidden sm:inline">Métrica:</span>
|
||||||
|
<div className="flex items-center bg-background border border-border rounded-md p-0.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={modoExibicao === 'kg' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setModoExibicao('kg')}
|
||||||
|
className="h-7 text-xs px-2.5 gap-1.5"
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
<Scale className="h-3.5 w-3.5" />
|
||||||
|
<span>Quilogramas (kg)</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={modoExibicao === 'percent' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setModoExibicao('percent')}
|
||||||
|
className="h-7 text-xs px-2.5 gap-1.5"
|
||||||
|
>
|
||||||
|
<Percent className="h-3.5 w-3.5" />
|
||||||
|
<span>Porcentagem (%)</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedProcesso && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSelectProcesso(selectedProcesso)}
|
||||||
|
className="h-7 text-xs gap-1 border-dashed"
|
||||||
|
>
|
||||||
|
<FilterX className="h-3.5 w-3.5" />
|
||||||
|
<span>Limpar seleção ({selectedProcesso})</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legenda Customizada em Ordem Linear Sequencial */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2 py-1">
|
||||||
|
{processosOrdenados.map((processo, idx) => {
|
||||||
|
const color = getCorProcesso(processo.nome, idx);
|
||||||
|
const isSelected = selectedProcesso === processo.nome;
|
||||||
|
const isDimmed = selectedProcesso && !isSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={processo.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectProcesso(processo.nome)}
|
||||||
|
className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary text-primary-foreground border-primary font-semibold shadow-md scale-105'
|
||||||
|
: isDimmed
|
||||||
|
? 'bg-muted/30 text-muted-foreground border-transparent opacity-40 hover:opacity-70'
|
||||||
|
: 'bg-muted/60 hover:bg-muted text-foreground border-border/60 hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-sm"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<span>{processo.nome}</span>
|
||||||
|
<span className="text-[10px] opacity-75 font-mono">
|
||||||
|
({modoExibicao === 'kg' ? `${Math.round(processo.pesoFabricado)}kg` : `${processo.progressoReal.toFixed(0)}%`})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Container de Altura Fixa com minWidth=0 para EVITAR Avisos no Console */}
|
||||||
|
<div className="w-full h-[380px] min-h-[380px] relative">
|
||||||
|
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
|
||||||
|
<LineChart
|
||||||
|
data={dadosGrafico}
|
||||||
|
margin={{ top: 15, right: 25, left: 10, bottom: 25 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border/40" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="data"
|
dataKey="data"
|
||||||
stroke="#9ca3af"
|
stroke="currentColor"
|
||||||
fontSize={12}
|
fontSize={11}
|
||||||
tick={{ fill: '#9ca3af' }}
|
className="text-muted-foreground"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: 'currentColor', opacity: 0.2 }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
stroke="#9ca3af"
|
stroke="currentColor"
|
||||||
fontSize={12}
|
fontSize={11}
|
||||||
tick={{ fill: '#9ca3af' }}
|
className="text-muted-foreground"
|
||||||
label={{
|
tickLine={false}
|
||||||
value: 'Peso Acumulado (t)',
|
axisLine={{ stroke: 'currentColor', opacity: 0.2 }}
|
||||||
angle: -90,
|
unit={modoExibicao === 'kg' ? ' kg' : '%'}
|
||||||
position: 'insideLeft',
|
tickFormatter={(val) => modoExibicao === 'kg' && val >= 1000 ? `${(val / 1000).toFixed(1)}k` : `${val}`}
|
||||||
style: { textAnchor: 'middle', fill: '#9ca3af' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#1f2937',
|
|
||||||
border: '1px solid #374151',
|
|
||||||
borderRadius: '6px',
|
|
||||||
color: '#f3f4f6'
|
|
||||||
}}
|
|
||||||
formatter={formatTooltipValue}
|
|
||||||
labelFormatter={(label) => `Data: ${label}`}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ color: '#9ca3af' }}
|
|
||||||
onClick={(e) => handleProcessoClick(e.value)}
|
|
||||||
/>
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip modoExibicao={modoExibicao} selectedProcesso={selectedProcesso} />} />
|
||||||
|
|
||||||
{processos.map((processo) => (
|
{processosOrdenados.map((processo, idx) => {
|
||||||
|
const color = getCorProcesso(processo.nome, idx);
|
||||||
|
const isSelected = selectedProcesso === processo.nome;
|
||||||
|
const isDimmed = selectedProcesso && !isSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
<Line
|
<Line
|
||||||
key={processo.nome}
|
key={processo.id}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={processo.nome}
|
dataKey={processo.nome}
|
||||||
stroke={obterCorProcesso(processo.status)}
|
name={processo.nome}
|
||||||
strokeWidth={2}
|
stroke={color}
|
||||||
dot={{ fill: obterCorProcesso(processo.status), strokeWidth: 2, r: 3 }}
|
strokeWidth={isSelected ? 3.5 : isDimmed ? 1 : 2.5}
|
||||||
activeDot={{ r: 5, fill: obterCorProcesso(processo.status) }}
|
strokeOpacity={isDimmed ? 0.2 : 1}
|
||||||
opacity={processoSelecionado ? (processoSelecionado === processo.nome ? 1 : 0.3) : 1}
|
dot={isSelected ? { fill: color, r: 4.5, strokeWidth: 2, stroke: '#fff' } : false}
|
||||||
style={{ cursor: 'pointer' }}
|
activeDot={{ r: 6, fill: color, stroke: '#fff', strokeWidth: 2 }}
|
||||||
|
connectNulls
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
|
import { DashboardProcesso } from '@/hooks/useDashboardProducaoOtimizado';
|
||||||
@@ -11,11 +10,16 @@ interface GraficoProgressoIndividualProps {
|
|||||||
|
|
||||||
export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProps> = ({ processos }) => {
|
export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProps> = ({ processos }) => {
|
||||||
const formatTooltipValue = (value: number, name: string) => [
|
const formatTooltipValue = (value: number, name: string) => [
|
||||||
`${(value / 1000).toFixed(2)} t`,
|
`${Math.round(value).toLocaleString('pt-BR')} kg`,
|
||||||
name === 'planejado' ? 'Planejado' : 'Realizado'
|
name === 'planejado' ? 'Planejado' : 'Realizado'
|
||||||
];
|
];
|
||||||
|
|
||||||
const formatAxisValue = (value: number) => `${(value / 1000).toFixed(1)}t`;
|
const formatAxisValue = (value: number) => {
|
||||||
|
if (value >= 1000) {
|
||||||
|
return `${(value / 1000).toFixed(1)}k kg`;
|
||||||
|
}
|
||||||
|
return `${Math.round(value)} kg`;
|
||||||
|
};
|
||||||
|
|
||||||
const formatDateLabel = (tickItem: string) => {
|
const formatDateLabel = (tickItem: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -55,13 +59,13 @@ export const GraficoProgressoIndividual: React.FC<GraficoProgressoIndividualProp
|
|||||||
<h3 className="text-lg font-semibold text-card-foreground">
|
<h3 className="text-lg font-semibold text-card-foreground">
|
||||||
Progresso - {processo.nome}
|
Progresso - {processo.nome}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="ml-auto text-sm text-muted-foreground">
|
<div className="ml-auto text-sm text-muted-foreground font-mono">
|
||||||
{processo.progressoReal.toFixed(1)}% realizado
|
{processo.progressoReal.toFixed(1)}% realizado ({Math.round(processo.pesoFabricado)} kg)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-64 w-full">
|
<div className="h-64 w-full relative min-h-[256px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
|
||||||
<AreaChart data={processo.dadosGrafico}>
|
<AreaChart data={processo.dadosGrafico}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||||
<XAxis
|
<XAxis
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ export const ResumoOF: React.FC<ResumoOFProps> = ({ of, data, loading }) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-card-foreground">
|
<div className="text-2xl font-bold text-card-foreground">
|
||||||
{(data.pesoTotalFabricado / 1000).toFixed(2)} t
|
{data.pesoTotalFabricado.toFixed(2)} kg
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
de {(data.tonelagem / 1000).toFixed(2)} t contratadas
|
de {data.tonelagem.toFixed(2)} kg contratadas
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const TabelaResumoProcessos: React.FC<TabelaResumoProcessosProps> = ({ da
|
|||||||
{getStatusBadge(processo.status)}
|
{getStatusBadge(processo.status)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right py-2">
|
<TableCell className="text-right py-2">
|
||||||
{(processo.pesoFabricado / 1000).toFixed(3)} t
|
{processo.pesoFabricado.toFixed(1)} kg
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -46,17 +46,18 @@ export function UserInfo() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchUserProfile() {
|
async function fetchUserProfile() {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const { data, error } = await supabase
|
const { data: profileArr, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('full_name, email, profile_image_url')
|
.select('full_name, email, profile_image_url')
|
||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.single();
|
.limit(1);
|
||||||
|
|
||||||
if (!error && data) {
|
if (!error && profileArr && profileArr.length > 0) {
|
||||||
setProfile(data);
|
setProfile(profileArr[0]);
|
||||||
} else {
|
} else {
|
||||||
setProfile({
|
setProfile({
|
||||||
full_name: user.user_metadata?.full_name || null,
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
full_name: (user as any).name || (user as any).username || null,
|
||||||
email: user.email || null,
|
email: user.email || null,
|
||||||
profile_image_url: null
|
profile_image_url: null
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
|||||||
peca_id,
|
peca_id,
|
||||||
quantidade_produzida,
|
quantidade_produzida,
|
||||||
processo_id,
|
processo_id,
|
||||||
processos_fabricacao!inner(nome),
|
processo:processos_fabricacao!inner!apontamentos_producao_processo_id_fkey(nome),
|
||||||
pecas!inner(
|
peca:pecas!inner!apontamentos_producao_peca_id_fkey(
|
||||||
id,
|
id,
|
||||||
marca,
|
marca,
|
||||||
descricao,
|
descricao,
|
||||||
@@ -115,43 +115,80 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
|||||||
of_number
|
of_number
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('pecas.of_number', romaneio.of_number)
|
.eq('peca.of_number', romaneio.of_number)
|
||||||
.eq('processos_fabricacao.nome', processo);
|
.eq('processo.nome', processo);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Erro ao buscar peças disponíveis:', error);
|
console.error('Erro ao buscar peças disponíveis:', error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agrupar por peça e calcular quantidade disponível
|
// 2. Buscar quanto dessa OF já foi embalado em TODOS os romaneios da OF
|
||||||
|
const { data: expedidosData, error: expError } = await supabase
|
||||||
|
.from('itens_romaneio_pecas')
|
||||||
|
.select(`
|
||||||
|
peca_id,
|
||||||
|
quantidade_expedida,
|
||||||
|
romaneio:romaneios_expedicao!inner!itens_romaneio_pecas_romaneio_id_fkey(of_number)
|
||||||
|
`)
|
||||||
|
.eq('romaneio.of_number', romaneio.of_number);
|
||||||
|
|
||||||
|
const expedidosMap = new Map<string, number>();
|
||||||
|
if (!expError && expedidosData) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(expedidosData as any[]).forEach((item: any) => {
|
||||||
|
const pid = item.peca_id;
|
||||||
|
const qtd = item.quantidade_expedida || 0;
|
||||||
|
const current = expedidosMap.get(pid) || 0;
|
||||||
|
expedidosMap.set(pid, current + qtd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agrupar por peça e calcular quantidade total produzida
|
||||||
const pecasAgrupadas = new Map<string, PecaDisponivel>();
|
const pecasAgrupadas = new Map<string, PecaDisponivel>();
|
||||||
|
|
||||||
apontamentosData?.forEach(apontamento => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const pecaId = apontamento.pecas.id;
|
(apontamentosData as any[])?.forEach((apontamento: any) => {
|
||||||
|
const pecaId = apontamento.peca?.id;
|
||||||
|
if (!pecaId) return;
|
||||||
|
|
||||||
const quantidade = apontamento.quantidade_produzida || 0;
|
const quantidade = apontamento.quantidade_produzida || 0;
|
||||||
|
|
||||||
if (pecasAgrupadas.has(pecaId)) {
|
if (pecasAgrupadas.has(pecaId)) {
|
||||||
const pecaExistente = pecasAgrupadas.get(pecaId)!;
|
const pecaExistente = pecasAgrupadas.get(pecaId)!;
|
||||||
pecaExistente.quantidade_disponivel += quantidade;
|
pecaExistente.quantidade_disponivel += quantidade; // Aqui salva o Bruto temporariamente
|
||||||
} else {
|
} else {
|
||||||
pecasAgrupadas.set(pecaId, {
|
pecasAgrupadas.set(pecaId, {
|
||||||
id: apontamento.pecas.id,
|
id: apontamento.peca.id,
|
||||||
marca: apontamento.pecas.marca,
|
marca: apontamento.peca.marca,
|
||||||
descricao: apontamento.pecas.descricao || '',
|
descricao: apontamento.peca.descricao || '',
|
||||||
etapa_fase: apontamento.pecas.etapa_fase || '',
|
etapa_fase: apontamento.peca.etapa_fase || '',
|
||||||
quantidade_disponivel: quantidade,
|
quantidade_disponivel: quantidade, // Bruto temporário
|
||||||
peso_unitario: apontamento.pecas.peso_unitario || 0,
|
peso_unitario: apontamento.peca.peso_unitario || 0,
|
||||||
prioridade: apontamento.pecas.prioridade || 'P4'
|
prioridade: apontamento.peca.prioridade || 'P4'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const pecasArray = Array.from(pecasAgrupadas.values());
|
// 4. Calcular o saldo Real (Produzido - Expedido) e limpar zerados
|
||||||
setPecasDisponiveis(pecasArray);
|
const pecasDisponiveisFinal: PecaDisponivel[] = [];
|
||||||
|
pecasAgrupadas.forEach((peca, pecaId) => {
|
||||||
|
const qtdExpedida = expedidosMap.get(pecaId) || 0;
|
||||||
|
const saldoReal = peca.quantidade_disponivel - qtdExpedida;
|
||||||
|
|
||||||
|
if (saldoReal > 0) {
|
||||||
|
pecasDisponiveisFinal.push({
|
||||||
|
...peca,
|
||||||
|
quantidade_disponivel: saldoReal // Retorna apenas o saldo disponível
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setPecasDisponiveis(pecasDisponiveisFinal);
|
||||||
|
|
||||||
// Extrair fases e marcas únicas
|
// Extrair fases e marcas únicas
|
||||||
const fasesUnicas = [...new Set(pecasArray.map(p => p.etapa_fase).filter(Boolean))];
|
const fasesUnicas = [...new Set(pecasDisponiveisFinal.map(p => p.etapa_fase).filter(Boolean))];
|
||||||
const marcasUnicas = [...new Set(pecasArray.map(p => p.marca).filter(Boolean))];
|
const marcasUnicas = [...new Set(pecasDisponiveisFinal.map(p => p.marca).filter(Boolean))];
|
||||||
|
|
||||||
setFasesDisponiveis(fasesUnicas);
|
setFasesDisponiveis(fasesUnicas);
|
||||||
setMarcasDisponiveis(marcasUnicas);
|
setMarcasDisponiveis(marcasUnicas);
|
||||||
@@ -180,6 +217,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
|||||||
if (pecasError) {
|
if (pecasError) {
|
||||||
console.error('Erro ao carregar peças do romaneio:', pecasError);
|
console.error('Erro ao carregar peças do romaneio:', pecasError);
|
||||||
} else if (pecasData) {
|
} else if (pecasData) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const itensPecaFormatados = pecasData.map((item: any) => ({
|
const itensPecaFormatados = pecasData.map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
peca_id: item.peca_id,
|
peca_id: item.peca_id,
|
||||||
@@ -203,6 +241,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
|||||||
if (insumosError) {
|
if (insumosError) {
|
||||||
console.error('Erro ao carregar insumos do romaneio:', insumosError);
|
console.error('Erro ao carregar insumos do romaneio:', insumosError);
|
||||||
} else if (insumosData) {
|
} else if (insumosData) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const itensInsumoFormatados = insumosData.map((item: any) => ({
|
const itensInsumoFormatados = insumosData.map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
nome: item.descricao,
|
nome: item.descricao,
|
||||||
@@ -225,6 +264,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
|||||||
if (processoSelecionado && isOpen) {
|
if (processoSelecionado && isOpen) {
|
||||||
buscarPecasDisponiveis(processoSelecionado);
|
buscarPecasDisponiveis(processoSelecionado);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [processoSelecionado, isOpen, romaneio.of_number]);
|
}, [processoSelecionado, isOpen, romaneio.of_number]);
|
||||||
|
|
||||||
// Definir processo padrão quando abrir o modal
|
// Definir processo padrão quando abrir o modal
|
||||||
@@ -238,6 +278,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
|||||||
setAdicionarTodas(false);
|
setAdicionarTodas(false);
|
||||||
carregarItensRomaneio();
|
carregarItensRomaneio();
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Filtrar peças baseado nos filtros selecionados
|
// Filtrar peças baseado nos filtros selecionados
|
||||||
@@ -306,6 +347,12 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const quantidade = parseInt(quantidadePeca);
|
const quantidade = parseInt(quantidadePeca);
|
||||||
|
|
||||||
|
if (quantidade > pecaSelecionada.quantidade_disponivel) {
|
||||||
|
toast.error(`Quantidade não autorizada! Saldo disponível é ${pecaSelecionada.quantidade_disponivel}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('itens_romaneio_pecas')
|
.from('itens_romaneio_pecas')
|
||||||
.insert({
|
.insert({
|
||||||
@@ -329,6 +376,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
|||||||
|
|
||||||
// Recarregar itens e limpar formulário
|
// Recarregar itens e limpar formulário
|
||||||
carregarItensRomaneio();
|
carregarItensRomaneio();
|
||||||
|
if (processoSelecionado) {
|
||||||
|
buscarPecasDisponiveis(processoSelecionado);
|
||||||
|
}
|
||||||
setPecaDisponivel('');
|
setPecaDisponivel('');
|
||||||
setQuantidadePeca('');
|
setQuantidadePeca('');
|
||||||
setAdicionarTodas(false);
|
setAdicionarTodas(false);
|
||||||
@@ -374,6 +424,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Item removido do romaneio');
|
toast.success('Item removido do romaneio');
|
||||||
carregarItensRomaneio();
|
carregarItensRomaneio();
|
||||||
|
if (processoSelecionado) {
|
||||||
|
buscarPecasDisponiveis(processoSelecionado);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao remover item:', error);
|
console.error('Erro ao remover item:', error);
|
||||||
toast.error('Erro ao remover item do romaneio');
|
toast.error('Erro ao remover item do romaneio');
|
||||||
@@ -572,6 +625,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
max={pecasDisponiveis.find(p => p.id === pecaDisponivel)?.quantidade_disponivel || 1}
|
||||||
value={quantidadePeca}
|
value={quantidadePeca}
|
||||||
onChange={(e) => setQuantidadePeca(e.target.value)}
|
onChange={(e) => setQuantidadePeca(e.target.value)}
|
||||||
placeholder="Quantidade"
|
placeholder="Quantidade"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,163 +53,69 @@ export const ProcessChartOptimized: React.FC<ProcessChartOptimizedProps> = ({
|
|||||||
const normalizedMontagemData = normalizeData(montagemData);
|
const normalizedMontagemData = normalizeData(montagemData);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'Inter, sans-serif' }} className="h-full">
|
<div style={{ fontFamily: 'Inter, sans-serif' }} className="h-full flex flex-col pt-2 pb-3">
|
||||||
<div
|
{/* Legenda Global */}
|
||||||
className="flex justify-evenly items-end h-full p-4 pt-6 pb-6 rounded-lg"
|
<div className="flex justify-center gap-4 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||||
style={{ height: '200px' }}
|
<div className="flex items-center gap-1.5">
|
||||||
>
|
<div className="w-3 h-3 bg-[#f97316] rounded-sm shadow-sm"></div>
|
||||||
{/* Grupo Corte */}
|
<span className="font-medium">7 a 15 dias</span>
|
||||||
<div className="flex flex-col justify-end items-center h-full flex-1">
|
|
||||||
<div
|
|
||||||
className="flex items-end justify-center w-full gap-1"
|
|
||||||
style={{ height: '120px' }}
|
|
||||||
>
|
|
||||||
{normalizedCorteData.map((data, index) => {
|
|
||||||
const colors = ['#f97316', '#10b981', '#ec4899']; // orange, green, pink
|
|
||||||
const labels = ['7-15d', 'ult.7d', 'prev.7d'];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="relative transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
width: '20px',
|
|
||||||
height: `${calculateHeight(data.weight)}%`,
|
|
||||||
backgroundColor: colors[index],
|
|
||||||
borderRadius: '4px 4px 0 0'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute w-full text-center text-xs font-semibold text-foreground"
|
|
||||||
style={{
|
|
||||||
top: '-20px',
|
|
||||||
fontSize: '10px',
|
|
||||||
lineHeight: '1.2'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatValue(data.weight)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="flex items-center gap-1.5">
|
||||||
className="absolute w-full text-center text-foreground whitespace-nowrap"
|
<div className="w-3 h-3 bg-[#10b981] rounded-sm shadow-sm"></div>
|
||||||
style={{
|
<span className="font-medium">Últimos 7 dias</span>
|
||||||
bottom: '-18px',
|
|
||||||
fontSize: '9px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{labels[index]}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-1.5">
|
||||||
);
|
<div className="w-3 h-3 bg-[#ec4899] rounded-sm shadow-sm"></div>
|
||||||
})}
|
<span className="font-medium">Meta (Próx. 7d)</span>
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="mt-4 text-muted-foreground font-medium"
|
|
||||||
style={{ fontSize: '11px' }}
|
|
||||||
>
|
|
||||||
Corte
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grupo Solda */}
|
|
||||||
<div className="flex flex-col justify-end items-center h-full flex-1">
|
|
||||||
<div
|
<div
|
||||||
className="flex items-end justify-center w-full gap-1"
|
className="flex justify-evenly items-end h-full px-2"
|
||||||
style={{ height: '120px' }}
|
style={{ minHeight: '130px' }}
|
||||||
>
|
>
|
||||||
{normalizedSoldaData.map((data, index) => {
|
{[
|
||||||
|
{ title: 'Corte', data: normalizedCorteData },
|
||||||
|
{ title: 'Solda', data: normalizedSoldaData },
|
||||||
|
{ title: 'Montagem', data: normalizedMontagemData }
|
||||||
|
].map((group, groupIdx) => (
|
||||||
|
<div key={groupIdx} className="flex flex-col justify-end items-center h-full flex-1">
|
||||||
|
<div
|
||||||
|
className="flex items-end justify-center w-full gap-1.5 sm:gap-2"
|
||||||
|
style={{ height: '100px' }}
|
||||||
|
>
|
||||||
|
{group.data.map((data, index) => {
|
||||||
const colors = ['#f97316', '#10b981', '#ec4899']; // orange, green, pink
|
const colors = ['#f97316', '#10b981', '#ec4899']; // orange, green, pink
|
||||||
const labels = ['7-15d', 'ult.7d', 'prev.7d'];
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="relative transition-all duration-500"
|
className="relative transition-all duration-500 shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
width: '20px',
|
width: '24px',
|
||||||
height: `${calculateHeight(data.weight)}%`,
|
height: `${calculateHeight(data.weight)}%`,
|
||||||
backgroundColor: colors[index],
|
backgroundColor: colors[index],
|
||||||
borderRadius: '4px 4px 0 0'
|
borderRadius: '4px 4px 0 0'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute w-full text-center text-xs font-semibold text-foreground"
|
className="absolute w-full text-center font-bold text-foreground"
|
||||||
style={{
|
style={{
|
||||||
top: '-20px',
|
top: '-22px',
|
||||||
fontSize: '10px',
|
fontSize: '11px',
|
||||||
lineHeight: '1.2'
|
lineHeight: '1.2'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatValue(data.weight)}
|
{formatValue(data.weight)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className="absolute w-full text-center text-foreground whitespace-nowrap"
|
|
||||||
style={{
|
|
||||||
bottom: '-18px',
|
|
||||||
fontSize: '9px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{labels[index]}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="mt-4 text-foreground font-semibold text-xs sm:text-sm border-t border-border pt-2 w-4/5 text-center">
|
||||||
className="mt-4 text-muted-foreground font-medium"
|
{group.title}
|
||||||
style={{ fontSize: '11px' }}
|
|
||||||
>
|
|
||||||
Solda
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grupo Montagem */}
|
|
||||||
<div className="flex flex-col justify-end items-center h-full flex-1">
|
|
||||||
<div
|
|
||||||
className="flex items-end justify-center w-full gap-1"
|
|
||||||
style={{ height: '120px' }}
|
|
||||||
>
|
|
||||||
{normalizedMontagemData.map((data, index) => {
|
|
||||||
const colors = ['#f97316', '#10b981', '#ec4899']; // orange, green, pink
|
|
||||||
const labels = ['7-15d', 'ult.7d', 'prev.7d'];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="relative transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
width: '20px',
|
|
||||||
height: `${calculateHeight(data.weight)}%`,
|
|
||||||
backgroundColor: colors[index],
|
|
||||||
borderRadius: '4px 4px 0 0'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute w-full text-center text-xs font-semibold text-foreground"
|
|
||||||
style={{
|
|
||||||
top: '-20px',
|
|
||||||
fontSize: '10px',
|
|
||||||
lineHeight: '1.2'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatValue(data.weight)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="absolute w-full text-center text-foreground whitespace-nowrap"
|
|
||||||
style={{
|
|
||||||
bottom: '-18px',
|
|
||||||
fontSize: '9px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{labels[index]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="mt-4 text-muted-foreground font-medium"
|
|
||||||
style={{ fontSize: '11px' }}
|
|
||||||
>
|
|
||||||
Mont. Obra
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -122,14 +122,9 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
|
|||||||
canAutoFix: /^[\d,\.]+$/.test(value.toString())
|
canAutoFix: /^[\d,\.]+$/.test(value.toString())
|
||||||
});
|
});
|
||||||
convertedData[field] = 0;
|
convertedData[field] = 0;
|
||||||
} else {
|
|
||||||
// Para peso_unitario e peso_total, arredondar para integer
|
|
||||||
if (field === 'peso_unitario' || field === 'peso_total') {
|
|
||||||
convertedData[field] = Math.round(numValue);
|
|
||||||
} else {
|
} else {
|
||||||
convertedData[field] = numValue;
|
convertedData[field] = numValue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
convertedData[field] = field.includes('componente') ? 0 : (field === 'quantidade' ? 1 : 0);
|
convertedData[field] = field.includes('componente') ? 0 : (field === 'quantidade' ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Upload, FileText, Trash2, Download, Undo2 } from 'lucide-react';
|
import { Upload, FileText, Trash2, Download, Undo2, FileSpreadsheet } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Peca } from '@/hooks/usePecas';
|
import { Peca } from '@/hooks/usePecas';
|
||||||
import { ImportarPecasModal } from './ImportarPecasModal';
|
import { ImportarPecasModal } from './ImportarPecasModal';
|
||||||
|
import { ImportarXLSModal } from './ImportarXLSModal';
|
||||||
|
|
||||||
interface PecaFormProps {
|
interface PecaFormProps {
|
||||||
ofNumbers: string[];
|
ofNumbers: string[];
|
||||||
|
ofDefault?: string;
|
||||||
onSave: (data: any) => Promise<boolean>;
|
onSave: (data: any) => Promise<boolean>;
|
||||||
onUpdate: (id: string, data: any) => Promise<boolean>;
|
onUpdate: (id: string, data: any) => Promise<boolean>;
|
||||||
onImportCSV: (file: File) => Promise<boolean>;
|
onImportCSV: (file: File) => Promise<boolean>;
|
||||||
@@ -30,6 +32,7 @@ interface PecaFormProps {
|
|||||||
|
|
||||||
export function PecaForm({
|
export function PecaForm({
|
||||||
ofNumbers,
|
ofNumbers,
|
||||||
|
ofDefault,
|
||||||
onSave,
|
onSave,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onImportCSV,
|
onImportCSV,
|
||||||
@@ -92,6 +95,7 @@ export function PecaForm({
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [showXLSModal, setShowXLSModal] = useState(false);
|
||||||
|
|
||||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
@@ -361,6 +365,16 @@ export function PecaForm({
|
|||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
Download Modelo CSV
|
Download Modelo CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowXLSModal(true)}
|
||||||
|
className="flex items-center gap-2 border-emerald-600 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30 font-semibold"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="h-4 w-4 text-emerald-600" />
|
||||||
|
Importar de XLS
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -385,6 +399,13 @@ export function PecaForm({
|
|||||||
etapa_fase: peca.etapa_fase
|
etapa_fase: peca.etapa_fase
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ImportarXLSModal
|
||||||
|
open={showXLSModal}
|
||||||
|
onOpenChange={setShowXLSModal}
|
||||||
|
onImport={onImportPecas}
|
||||||
|
ofDefault={formData.of_number || ofDefault || (ofNumbers && ofNumbers[0]) || ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function TableRow({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="w-16 px-2 text-right font-medium text-foreground text-xs">
|
<TableCell className="w-16 px-2 text-right font-medium text-foreground text-xs">
|
||||||
{(peca.quantidade * peca.peso_unitario).toFixed(2)} kg
|
{(peca.peso_total || (peca.quantidade * peca.peso_unitario)).toFixed(2)} kg
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="w-16 px-2 text-muted-foreground text-xs">
|
<TableCell className="w-16 px-2 text-muted-foreground text-xs">
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PrioridadesPDFTemplate } from './PrioridadesPDFTemplate';
|
import { PrioridadesPDFTemplate } from './PrioridadesPDFTemplate';
|
||||||
import { ItemPrioridade } from '@/hooks/useItensPrioridadeFabricacao';
|
import { ItemPrioridade } from '@/hooks/useItensPrioridadeFabricacao';
|
||||||
import { Download } from 'lucide-react';
|
import { Download, Loader2, Printer } from 'lucide-react';
|
||||||
import html2canvas from 'html2canvas';
|
import { generateProfessionalPDF, printProfessionalPDF } from '@/utils/pdfGenerator';
|
||||||
import jsPDF from 'jspdf';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface PrioridadesPDFProps {
|
interface PrioridadesPDFProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
itensPorPrioridade: { [key: string]: ItemPrioridade[] };
|
itensPorPrioridade: { [key: string]: ItemPrioridade[] };
|
||||||
|
ofSelecionada?: string | null;
|
||||||
|
faseSelecionada?: string | null;
|
||||||
versaoAtual?: {
|
versaoAtual?: {
|
||||||
revisao: number;
|
revisao: number;
|
||||||
dataModificacao: string;
|
dataModificacao: string;
|
||||||
@@ -23,52 +25,19 @@ export const PrioridadesPDF: React.FC<PrioridadesPDFProps> = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
itensPorPrioridade,
|
itensPorPrioridade,
|
||||||
|
ofSelecionada,
|
||||||
|
faseSelecionada,
|
||||||
versaoAtual
|
versaoAtual
|
||||||
}) => {
|
}) => {
|
||||||
const handleGerarPDF = async () => {
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const elemento = document.getElementById('prioridades-pdf-content');
|
const [isPrinting, setIsPrinting] = useState(false);
|
||||||
|
|
||||||
if (!elemento) {
|
const getNomeArquivo = () => {
|
||||||
console.error('Elemento não encontrado');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const canvas = await html2canvas(elemento, {
|
|
||||||
scale: 2,
|
|
||||||
useCORS: true,
|
|
||||||
allowTaint: true,
|
|
||||||
backgroundColor: '#ffffff'
|
|
||||||
});
|
|
||||||
|
|
||||||
const imgData = canvas.toDataURL('image/png');
|
|
||||||
const pdf = new jsPDF('p', 'mm', 'a4');
|
|
||||||
|
|
||||||
const imgWidth = 210;
|
|
||||||
const pageHeight = 295;
|
|
||||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
|
||||||
let heightLeft = imgHeight;
|
|
||||||
|
|
||||||
let position = 0;
|
|
||||||
|
|
||||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
|
||||||
heightLeft -= pageHeight;
|
|
||||||
|
|
||||||
while (heightLeft >= 0) {
|
|
||||||
position = heightLeft - imgHeight;
|
|
||||||
pdf.addPage();
|
|
||||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
|
||||||
heightLeft -= pageHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extrair OF e Fase para o nome do arquivo
|
|
||||||
const todosItens = Object.values(itensPorPrioridade).flat();
|
|
||||||
let nomeArquivo = 'checklist-producao';
|
let nomeArquivo = 'checklist-producao';
|
||||||
|
const todosItens = Object.values(itensPorPrioridade).flat();
|
||||||
if (todosItens.length > 0) {
|
|
||||||
const primeiroItem = todosItens[0];
|
const primeiroItem = todosItens[0];
|
||||||
const of = primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number;
|
const of = ofSelecionada || primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number;
|
||||||
const fase = primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase;
|
const fase = faseSelecionada || primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase;
|
||||||
|
|
||||||
if (of && fase) {
|
if (of && fase) {
|
||||||
nomeArquivo = `checklist-producao-${of}-${fase}`;
|
nomeArquivo = `checklist-producao-${of}-${fase}`;
|
||||||
@@ -76,11 +45,46 @@ export const PrioridadesPDF: React.FC<PrioridadesPDFProps> = ({
|
|||||||
nomeArquivo += `-rev${versaoAtual.revisao}`;
|
nomeArquivo += `-rev${versaoAtual.revisao}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nomeArquivo;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGerarPDF = async () => {
|
||||||
|
const elemento = document.getElementById('prioridades-pdf-content');
|
||||||
|
|
||||||
|
if (!elemento) {
|
||||||
|
toast.error('Elemento do relatório não encontrado');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.save(`${nomeArquivo}.pdf`);
|
try {
|
||||||
} catch (error) {
|
setIsGenerating(true);
|
||||||
|
const nomeArquivo = getNomeArquivo();
|
||||||
|
await generateProfessionalPDF('prioridades-pdf-content', `${nomeArquivo}.pdf`);
|
||||||
|
toast.success('PDF baixado com sucesso!');
|
||||||
|
} catch (error: any) {
|
||||||
console.error('Erro ao gerar PDF:', error);
|
console.error('Erro ao gerar PDF:', error);
|
||||||
|
toast.error(error.message || 'Erro ao gerar PDF');
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImprimir = async () => {
|
||||||
|
const elemento = document.getElementById('prioridades-pdf-content');
|
||||||
|
if (!elemento) {
|
||||||
|
toast.error('Elemento do relatório não encontrado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsPrinting(true);
|
||||||
|
await printProfessionalPDF('prioridades-pdf-content');
|
||||||
|
toast.success('Relatório enviado para impressão');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Erro ao imprimir:', error);
|
||||||
|
toast.error('Erro ao imprimir relatório');
|
||||||
|
} finally {
|
||||||
|
setIsPrinting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,18 +92,53 @@ export const PrioridadesPDF: React.FC<PrioridadesPDFProps> = ({
|
|||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center justify-between">
|
<DialogTitle className="flex flex-wrap items-center justify-between gap-2">
|
||||||
Visualizar Checklist de Produção
|
<span>Visualizar Checklist de Produção</span>
|
||||||
<Button onClick={handleGerarPDF} className="ml-4">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleImprimir}
|
||||||
|
disabled={isPrinting || isGenerating}
|
||||||
|
>
|
||||||
|
{isPrinting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Imprimindo...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Printer className="h-4 w-4 mr-2" />
|
||||||
|
Imprimir
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGerarPDF}
|
||||||
|
disabled={isGenerating || isPrinting}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Gerando PDF...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Baixar PDF
|
Baixar PDF
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<PrioridadesPDFTemplate
|
<PrioridadesPDFTemplate
|
||||||
itensPorPrioridade={itensPorPrioridade}
|
itensPorPrioridade={itensPorPrioridade}
|
||||||
|
ofSelecionada={ofSelecionada}
|
||||||
|
faseSelecionada={faseSelecionada}
|
||||||
versaoAtual={versaoAtual}
|
versaoAtual={versaoAtual}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ItemPrioridade } from '@/hooks/useItensPrioridadeFabricacao';
|
|||||||
|
|
||||||
interface PrioridadesPDFTemplateProps {
|
interface PrioridadesPDFTemplateProps {
|
||||||
itensPorPrioridade: { [key: string]: ItemPrioridade[] };
|
itensPorPrioridade: { [key: string]: ItemPrioridade[] };
|
||||||
|
ofSelecionada?: string | null;
|
||||||
|
faseSelecionada?: string | null;
|
||||||
versaoAtual?: {
|
versaoAtual?: {
|
||||||
revisao: number;
|
revisao: number;
|
||||||
dataModificacao: string;
|
dataModificacao: string;
|
||||||
@@ -13,13 +15,15 @@ interface PrioridadesPDFTemplateProps {
|
|||||||
|
|
||||||
export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
||||||
itensPorPrioridade,
|
itensPorPrioridade,
|
||||||
|
ofSelecionada,
|
||||||
|
faseSelecionada,
|
||||||
versaoAtual
|
versaoAtual
|
||||||
}) => {
|
}) => {
|
||||||
const todosItens = Object.values(itensPorPrioridade).flat();
|
const todosItens = Object.values(itensPorPrioridade).flat();
|
||||||
|
|
||||||
const primeiroItem = todosItens[0];
|
const primeiroItem = todosItens[0];
|
||||||
const ofNumber = primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number || 'N/A';
|
const ofNumber = ofSelecionada || primeiroItem?.peca?.of_number || primeiroItem?.prioridade_fabricacao?.of_number || 'N/A';
|
||||||
const etapaFase = primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase || 'N/A';
|
const etapaFase = faseSelecionada || primeiroItem?.peca?.etapa_fase || primeiroItem?.prioridade_fabricacao?.etapa_fase || 'N/A';
|
||||||
|
|
||||||
const dataAtual = new Date().toLocaleDateString('pt-BR');
|
const dataAtual = new Date().toLocaleDateString('pt-BR');
|
||||||
|
|
||||||
@@ -35,11 +39,11 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
|||||||
|
|
||||||
const getCoresPrioridade = (codigo: string) => {
|
const getCoresPrioridade = (codigo: string) => {
|
||||||
switch (codigo) {
|
switch (codigo) {
|
||||||
case 'P1': return 'text-red-700 bg-red-100';
|
case 'P1': return 'text-red-700 bg-red-100 border-red-300';
|
||||||
case 'P2': return 'text-orange-700 bg-orange-100';
|
case 'P2': return 'text-orange-700 bg-orange-100 border-orange-300';
|
||||||
case 'P3': return 'text-blue-700 bg-blue-100';
|
case 'P3': return 'text-blue-700 bg-blue-100 border-blue-300';
|
||||||
case 'P4': return 'text-gray-700 bg-gray-200';
|
case 'P4': return 'text-gray-700 bg-gray-200 border-gray-300';
|
||||||
default: return 'text-gray-700 bg-gray-200';
|
default: return 'text-gray-700 bg-gray-200 border-gray-300';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,66 +54,83 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
|||||||
const numBigBoxes = Math.floor(quantity / 5);
|
const numBigBoxes = Math.floor(quantity / 5);
|
||||||
const numSmallBoxes = quantity % 5;
|
const numSmallBoxes = quantity % 5;
|
||||||
|
|
||||||
// Quadrados grandes com "5"
|
// Quadrados com "5"
|
||||||
for (let i = 0; i < numBigBoxes; i++) {
|
for (let i = 0; i < numBigBoxes; i++) {
|
||||||
boxes.push(
|
boxes.push(
|
||||||
<div key={`big-${i}`} className="tick-box-large">
|
<svg
|
||||||
<span>5</span>
|
key={`big-${i}`}
|
||||||
</div>
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 13 13"
|
||||||
|
style={{ display: 'inline-block', verticalAlign: '-1px', marginRight: '2px' }}
|
||||||
|
>
|
||||||
|
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#f3f4f6" stroke="#4b5563" strokeWidth="1" />
|
||||||
|
<text x="6.5" y="9.5" textAnchor="middle" fontSize="8.5" fontFamily="Arial, sans-serif" fontWeight="bold" fill="#4b5563">5</text>
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quadrados pequenos restantes
|
// Quadrados unitários restantes
|
||||||
for (let i = 0; i < numSmallBoxes; i++) {
|
for (let i = 0; i < numSmallBoxes; i++) {
|
||||||
boxes.push(<div key={`small-${i}`} className="tick-box"></div>);
|
boxes.push(
|
||||||
|
<svg
|
||||||
|
key={`small-${i}`}
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 13 13"
|
||||||
|
style={{ display: 'inline-block', verticalAlign: '-1px', marginRight: '2px' }}
|
||||||
|
>
|
||||||
|
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Apenas quadrados pequenos
|
// Apenas quadrados unitários
|
||||||
for (let i = 0; i < quantity; i++) {
|
for (let i = 0; i < quantity; i++) {
|
||||||
boxes.push(<div key={i} className="tick-box"></div>);
|
boxes.push(
|
||||||
|
<svg
|
||||||
|
key={i}
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 13 13"
|
||||||
|
style={{ display: 'inline-block', verticalAlign: '-1px', marginRight: '2px' }}
|
||||||
|
>
|
||||||
|
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="flex items-center flex-wrap gap-1">{boxes}</div>;
|
return <span style={{ display: 'inline-block', verticalAlign: 'middle', marginLeft: '4px' }}>{boxes}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="prioridades-pdf-content" className="bg-white text-black max-w-4xl mx-auto p-6">
|
<div id="prioridades-pdf-content" className="bg-white text-black max-w-4xl mx-auto p-6">
|
||||||
<style>{`
|
<style>{`
|
||||||
.tick-box {
|
.checklist-container {
|
||||||
width: 12px;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
height: 12px;
|
color: #111827;
|
||||||
border: 1px solid #6b7280;
|
|
||||||
display: inline-block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tick-box-large {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 1px solid #6b7280;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tick-box-large span {
|
|
||||||
color: #d1d5db;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
.item-card {
|
.item-card {
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
padding: 8px;
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.item-signature {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #4b5563;
|
||||||
|
border-bottom: 1px solid #9ca3af;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
height: 18px;
|
||||||
|
line-height: 14px;
|
||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
body {
|
body {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
.check-box-print {
|
|
||||||
border: 1px solid #333 !important;
|
|
||||||
}
|
|
||||||
.page-break {
|
.page-break {
|
||||||
page-break-before: always;
|
page-break-before: always;
|
||||||
}
|
}
|
||||||
@@ -122,43 +143,61 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
|
<div className="checklist-container">
|
||||||
{/* Cabeçalho do Relatório */}
|
{/* Cabeçalho do Relatório */}
|
||||||
<div className="flex justify-between items-center border-b-2 border-gray-800 pb-4 mb-4">
|
<div className="flex justify-between items-center border-b-2 border-gray-800 pb-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Checklist de Produção</h1>
|
<h1 className="text-2xl font-bold text-gray-900 leading-tight">Checklist de Produção</h1>
|
||||||
<p className="text-gray-600">Formulário para apontamento da fabricação.</p>
|
<p className="text-xs text-gray-600">Formulário para apontamento da fabricação.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="font-semibold">
|
<p className="font-semibold text-sm">
|
||||||
Data de Emissão: <span className="font-normal">{dataAtual}</span>
|
Data de Emissão: <span className="font-normal">{dataAtual}</span>
|
||||||
{versaoAtual && (
|
{versaoAtual && (
|
||||||
<span className="ml-2 text-gray-500">Rev. {versaoAtual.revisao}</span>
|
<span className="ml-2 text-gray-500 font-medium">Rev. {versaoAtual.revisao}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Informações da OF e Fase */}
|
{/* Informações da OF e Fase */}
|
||||||
<div className="border border-gray-200 bg-white p-4 rounded-lg mb-2">
|
<div className="border border-gray-200 bg-white p-3.5 rounded-lg mb-3">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-3">
|
||||||
{/* Coluna OF */}
|
{/* Coluna OF */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
|
<p className="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
|
||||||
<p className="text-base font-bold text-gray-800">{ofNumber}</p>
|
<p className="text-base font-bold text-gray-800 leading-snug">{ofNumber}</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Coluna Fase */}
|
{/* Coluna Fase */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500">Fase</p>
|
<p className="text-xs font-medium text-gray-500">Fase</p>
|
||||||
<p className="text-base font-bold text-gray-800">{etapaFase}</p>
|
<p className="text-base font-bold text-gray-800 leading-snug">{etapaFase}</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Coluna Processo */}
|
{/* Coluna Processo */}
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<p className="text-xs font-medium text-gray-500">PROCESSO</p>
|
<p className="text-xs font-medium text-gray-500 mb-1">PROCESSO</p>
|
||||||
<div className="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1">
|
<div style={{ marginTop: '2px' }}>
|
||||||
{['Corte', 'Solda', 'Pintura', 'Expedição'].map((processo) => (
|
{['Corte', 'Solda', 'Pintura', 'Expedição'].map((processo) => (
|
||||||
<div key={processo} className="flex items-center gap-1">
|
<div
|
||||||
<div className="w-4 h-4 border-2 border-gray-500 check-box-print"></div>
|
key={processo}
|
||||||
<span className="text-sm font-semibold text-gray-700">{processo}</span>
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
marginRight: '16px',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
style={{ display: 'inline-block', verticalAlign: '-2px', marginRight: '4px' }}
|
||||||
|
>
|
||||||
|
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: '#374151', verticalAlign: 'middle' }}>
|
||||||
|
{processo}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,32 +206,39 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legenda */}
|
{/* Legenda */}
|
||||||
<div className="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3">
|
<div className="text-xs text-gray-600 mb-5 flex items-center flex-wrap">
|
||||||
<span className="font-semibold">Legenda:</span>
|
<span className="font-semibold" style={{ marginRight: '6px' }}>Legenda:</span>
|
||||||
<span>Marca (Qtd)</span>
|
<span style={{ marginRight: '6px' }}>Marca (Qtd)</span>
|
||||||
<span className="font-medium text-gray-500">(S/M)</span>
|
<span className="font-medium text-gray-500" style={{ marginRight: '4px' }}>(S/M)</span>
|
||||||
<span>= Sem Montagem,</span>
|
<span style={{ marginRight: '6px' }}>= Sem Montagem,</span>
|
||||||
<span className="font-medium text-gray-500">(C/M)</span>
|
<span className="font-medium text-gray-500" style={{ marginRight: '4px' }}>(C/M)</span>
|
||||||
<span>= Com Montagem. Os quadrados</span>
|
<span style={{ marginRight: '4px' }}>= Com Montagem. Os quadrados</span>
|
||||||
<div className="tick-box inline-block"></div>
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 13 13"
|
||||||
|
style={{ display: 'inline-block', verticalAlign: '-2px', margin: '0 4px' }}
|
||||||
|
>
|
||||||
|
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" strokeWidth="1" />
|
||||||
|
</svg>
|
||||||
<span>indicam o controle de peças fabricadas.</span>
|
<span>indicam o controle de peças fabricadas.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Itens por Prioridade */}
|
{/* Itens por Prioridade */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
{['P1', 'P2', 'P3', 'P4'].map((codigo, priorityIndex) => {
|
{['P1', 'P2', 'P3', 'P4'].map((codigo, priorityIndex) => {
|
||||||
const itens = itensPorPrioridade[codigo] || [];
|
const itens = itensPorPrioridade[codigo] || [];
|
||||||
if (itens.length === 0) return null;
|
if (itens.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={codigo} className={priorityIndex > 0 ? 'page-break' : ''}>
|
<div key={codigo} className={priorityIndex > 0 ? 'page-break' : ''}>
|
||||||
<h2 className={`text-lg font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-3`}>
|
<h2 className={`text-base font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-2.5 border`}>
|
||||||
{getPrioridadeNome(codigo)}
|
{getPrioridadeNome(codigo)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
{Array.from({ length: Math.ceil(itens.length / 3) }, (_, i) => {
|
{Array.from({ length: Math.ceil(itens.length / 3) }, (_, i) => {
|
||||||
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50' : 'bg-white';
|
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50/70' : 'bg-white';
|
||||||
const rowItems = itens.slice(i * 3, (i + 1) * 3);
|
const rowItems = itens.slice(i * 3, (i + 1) * 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -205,15 +251,17 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="item-card">
|
<div key={item.id} className="item-card">
|
||||||
<div className="flex items-center flex-wrap gap-2 mb-2">
|
<div style={{ marginBottom: '6px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
<span className="font-semibold text-sm whitespace-nowrap">
|
<span style={{ fontSize: '13px', fontWeight: 700, color: '#111827', verticalAlign: 'middle', marginRight: '4px' }}>
|
||||||
{marca} ({quantidade})
|
{marca} ({quantidade})
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium text-gray-500">{infoType}</span>
|
<span style={{ fontSize: '11px', fontWeight: 600, color: '#6b7280', verticalAlign: 'middle' }}>
|
||||||
|
{infoType}
|
||||||
|
</span>
|
||||||
{generateTickBoxes(quantidade)}
|
{generateTickBoxes(quantidade)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs">
|
<div className="item-signature">
|
||||||
<div className="border-b border-gray-400 pb-1 h-5">Data/Operador:</div>
|
Data/Operador:
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -232,5 +280,6 @@ export const PrioridadesPDFTemplate: React.FC<PrioridadesPDFTemplateProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,22 +78,26 @@ export const ApontamentoDiarioChart = () => {
|
|||||||
return { valorMaximo, linhasGrid: linhas };
|
return { valorMaximo, linhasGrid: linhas };
|
||||||
}, [dadosProcessados]);
|
}, [dadosProcessados]);
|
||||||
|
|
||||||
// Cores para cada OF
|
// Array de cores profissionais e vibrantes para as OFs
|
||||||
const coresOF = {
|
const availableColors = [
|
||||||
'B114': 'bg-blue-500',
|
'bg-blue-500',
|
||||||
'B117': 'bg-amber-500',
|
'bg-emerald-500',
|
||||||
'B118': 'bg-emerald-500',
|
'bg-violet-500',
|
||||||
'B119': 'bg-purple-500',
|
'bg-amber-500',
|
||||||
'B120': 'bg-pink-500',
|
'bg-rose-500',
|
||||||
'B121': 'bg-indigo-500',
|
'bg-cyan-500',
|
||||||
'B122': 'bg-cyan-500',
|
'bg-fuchsia-500',
|
||||||
'B123': 'bg-teal-500',
|
'bg-lime-500',
|
||||||
'B124': 'bg-lime-500',
|
'bg-indigo-500',
|
||||||
'B125': 'bg-red-500',
|
'bg-orange-500',
|
||||||
};
|
'bg-teal-500',
|
||||||
|
'bg-pink-500'
|
||||||
|
];
|
||||||
|
|
||||||
const getCorOF = (ofNumber: string) => {
|
const getCorOF = (ofNumber: string) => {
|
||||||
return coresOF[ofNumber as keyof typeof coresOF] || 'bg-gray-500';
|
const index = ofsUnicas.indexOf(ofNumber);
|
||||||
|
if (index === -1) return 'bg-gray-500';
|
||||||
|
return availableColors[index % availableColors.length];
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttons = [
|
const buttons = [
|
||||||
@@ -222,19 +226,19 @@ export const ApontamentoDiarioChart = () => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Legenda interativa */}
|
{/* Legenda interativa */}
|
||||||
<div className="flex justify-center flex-wrap gap-4 mb-8">
|
<div className="flex justify-center flex-wrap gap-3 mb-8">
|
||||||
{ofsUnicas.map((ofNumber) => (
|
{ofsUnicas.map((ofNumber) => (
|
||||||
<button
|
<button
|
||||||
key={ofNumber}
|
key={ofNumber}
|
||||||
onClick={() => setSelectedOF(selectedOF === ofNumber ? null : ofNumber)}
|
onClick={() => setSelectedOF(selectedOF === ofNumber ? null : ofNumber)}
|
||||||
className={`flex items-center gap-2 px-3 py-1 rounded-md transition-all ${
|
className={`flex items-center gap-2 px-4 py-1.5 rounded-full border shadow-sm transition-all duration-200 ${
|
||||||
selectedOF === ofNumber
|
selectedOF === ofNumber
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground border-primary scale-105 shadow-md'
|
||||||
: 'hover:bg-muted'
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`w-4 h-4 rounded-sm ${getCorOF(ofNumber)}`} />
|
<div className={`w-3 h-3 rounded-full shadow-inner ${getCorOF(ofNumber)}`} />
|
||||||
<span className="text-sm font-medium">OF {ofNumber}</span>
|
<span className="text-sm font-semibold">OF {ofNumber}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{selectedOF && (
|
{selectedOF && (
|
||||||
@@ -262,7 +266,7 @@ export const ApontamentoDiarioChart = () => {
|
|||||||
style={{ bottom: `${linha.porcentagem}%`, transform: 'translateY(50%)' }}
|
style={{ bottom: `${linha.porcentagem}%`, transform: 'translateY(50%)' }}
|
||||||
>
|
>
|
||||||
<span className="text-xs text-muted-foreground mr-2">
|
<span className="text-xs text-muted-foreground mr-2">
|
||||||
{linha.valor >= 1000 ? `${(linha.valor/1000).toFixed(0)}t` : `${linha.valor}kg`}
|
{Math.round(linha.valor).toLocaleString('pt-BR')} Kg
|
||||||
</span>
|
</span>
|
||||||
<div className="w-2 h-px bg-border"></div>
|
<div className="w-2 h-px bg-border"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,7 +319,7 @@ export const ApontamentoDiarioChart = () => {
|
|||||||
>
|
>
|
||||||
{alturaPixels > 20 && (
|
{alturaPixels > 20 && (
|
||||||
<span className="transform rotate-90 whitespace-nowrap">
|
<span className="transform rotate-90 whitespace-nowrap">
|
||||||
{ofData.peso > 999 ? `${Math.round(ofData.peso/1000)}t` : `${ofData.peso}kg`}
|
{Math.round(ofData.peso).toLocaleString('pt-BR')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,14 +47,8 @@ export const RelatorioPecasProcessoModal: React.FC<RelatorioPecasProcessoModalPr
|
|||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto bg-card border-border">
|
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto bg-card border-border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center justify-between text-card-foreground">
|
<DialogTitle className="text-card-foreground">
|
||||||
<span>Relatório de Peças por Processo</span>
|
Relatório de Peças por Processo
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -33,37 +33,50 @@ export const RelatorioPecasProcessoPDF: React.FC<RelatorioPecasProcessoPDFProps>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new jsPDF('l', 'mm', 'a4'); // Landscape orientation
|
const doc = new jsPDF('p', 'mm', 'a4'); // Portrait orientation para otimizar espaço
|
||||||
|
|
||||||
const faseText = selectedFase === 'todas' ? '' : ` - Fase ${selectedFase}`;
|
const faseText = selectedFase === 'todas' ? '' : ` - Fase ${selectedFase}`;
|
||||||
|
|
||||||
// Título
|
// Título
|
||||||
doc.setFontSize(20);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('Relatório de Status', 20, 20);
|
|
||||||
|
|
||||||
// Subtítulo
|
|
||||||
doc.setFontSize(14);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.text(`Ordem de Fabricação: ${selectedOF}${faseText}`, 20, 30);
|
|
||||||
|
|
||||||
// Data/hora
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 20, 40);
|
|
||||||
|
|
||||||
// Resumo por Processo
|
|
||||||
doc.setFontSize(16);
|
doc.setFontSize(16);
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.text('Resumo por Processo', 20, 55);
|
doc.text('Relatório de Status de Produção', 14, 16);
|
||||||
|
|
||||||
|
// Subtítulo e Data
|
||||||
doc.setFontSize(10);
|
doc.setFontSize(10);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
let yPos = 65;
|
doc.text(`OF: ${selectedOF}${faseText} | Gerado em: ${new Date().toLocaleString('pt-BR')}`, 14, 23);
|
||||||
doc.text(`Total de Peças: ${estatisticas.totalPecas}`, 20, yPos);
|
|
||||||
doc.text(`Peso Corte: ${estatisticas.pesoTotalCorte.toFixed(2)} kg`, 80, yPos);
|
// Resumo por Processo em linha mais compacta
|
||||||
doc.text(`Peso Solda: ${estatisticas.pesoTotalSolda.toFixed(2)} kg`, 140, yPos);
|
let yPos = 32;
|
||||||
doc.text(`Peso Pintura: ${estatisticas.pesoTotalPintura.toFixed(2)} kg`, 200, yPos);
|
doc.setFontSize(9);
|
||||||
doc.text(`Peso Expedição: ${estatisticas.pesoTotalExpedicao.toFixed(2)} kg`, 260, yPos);
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`Total Peças:`, 14, yPos);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(`${estatisticas.totalPecas}`, 36, yPos);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`Corte:`, 50, yPos);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(`${estatisticas.pesoTotalCorte.toFixed(0)} kg`, 62, yPos);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`Solda:`, 90, yPos);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(`${estatisticas.pesoTotalSolda.toFixed(0)} kg`, 102, yPos);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`Pintura:`, 130, yPos);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(`${estatisticas.pesoTotalPintura.toFixed(0)} kg`, 145, yPos);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`Expedição:`, 175, yPos);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(`${estatisticas.pesoTotalExpedicao.toFixed(0)} kg`, 192, yPos);
|
||||||
|
|
||||||
|
yPos = 38; // Ajusta yPos para o início da tabela
|
||||||
|
|
||||||
// Preparar dados para a tabela
|
// Preparar dados para a tabela
|
||||||
const tableData = pecasComStatus.map(peca => [
|
const tableData = pecasComStatus.map(peca => [
|
||||||
@@ -88,36 +101,35 @@ export const RelatorioPecasProcessoPDF: React.FC<RelatorioPecasProcessoPDFProps>
|
|||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
head: headers,
|
head: headers,
|
||||||
body: tableData,
|
body: tableData,
|
||||||
startY: yPos + 15,
|
startY: yPos,
|
||||||
styles: {
|
styles: {
|
||||||
fontSize: 9,
|
fontSize: 8,
|
||||||
cellPadding: 2,
|
cellPadding: 1.5,
|
||||||
lineColor: [204, 204, 204],
|
lineColor: [210, 210, 210],
|
||||||
lineWidth: 0.5,
|
lineWidth: 0.1,
|
||||||
},
|
},
|
||||||
headStyles: {
|
headStyles: {
|
||||||
fillColor: [233, 236, 239],
|
fillColor: [240, 240, 240],
|
||||||
textColor: 0,
|
textColor: [40, 40, 40],
|
||||||
fontStyle: 'bold',
|
fontStyle: 'bold',
|
||||||
lineColor: [204, 204, 204],
|
lineWidth: 0.1,
|
||||||
lineWidth: 0.5,
|
|
||||||
},
|
},
|
||||||
alternateRowStyles: {
|
alternateRowStyles: {
|
||||||
fillColor: [248, 249, 250],
|
fillColor: [250, 250, 250],
|
||||||
},
|
},
|
||||||
columnStyles: {
|
columnStyles: {
|
||||||
0: { cellWidth: 25, halign: 'center' }, // OF
|
0: { cellWidth: 18, halign: 'center' }, // OF
|
||||||
1: { cellWidth: 20, halign: 'center' }, // Fase
|
1: { cellWidth: 12, halign: 'center' }, // Fase
|
||||||
2: { cellWidth: 25, halign: 'center' }, // Marca
|
2: { cellWidth: 20, halign: 'center' }, // Marca
|
||||||
3: { cellWidth: 15, halign: 'center' }, // Qtd
|
3: { cellWidth: 10, halign: 'center' }, // Qtd
|
||||||
4: { cellWidth: 25, halign: 'right' }, // Peso Unit
|
4: { cellWidth: 22, halign: 'right' }, // Peso Unit
|
||||||
5: { cellWidth: 25, halign: 'right' }, // Peso Total
|
5: { cellWidth: 22, halign: 'right' }, // Peso Total
|
||||||
6: { cellWidth: 20, halign: 'center' }, // Corte
|
6: { cellWidth: 16, halign: 'center' }, // Corte
|
||||||
7: { cellWidth: 20, halign: 'center' }, // Solda
|
7: { cellWidth: 16, halign: 'center' }, // Solda
|
||||||
8: { cellWidth: 25, halign: 'center' }, // Pint/Galv
|
8: { cellWidth: 22, halign: 'center' }, // Pint/Galv
|
||||||
9: { cellWidth: 25, halign: 'center' }, // Expedição
|
9: { cellWidth: 22, halign: 'center' }, // Expedição
|
||||||
},
|
},
|
||||||
margin: { left: 20, right: 20 },
|
margin: { left: 14, right: 14, top: 20, bottom: 20 },
|
||||||
tableWidth: 'auto',
|
tableWidth: 'auto',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,15 +41,18 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
|
|||||||
printTemplate.innerHTML = `
|
printTemplate.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
@media print {
|
@media print {
|
||||||
|
@page { size: A4 portrait; margin: 1cm; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
background: white;
|
background: white;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
.a4-page {
|
.a4-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1.5cm;
|
padding: 0;
|
||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
table {
|
table {
|
||||||
@@ -58,18 +61,19 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
|
|||||||
page-break-inside: auto;
|
page-break-inside: auto;
|
||||||
}
|
}
|
||||||
th, td {
|
th, td {
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #e5e7eb;
|
||||||
padding: 4px 8px;
|
padding: 3px 6px;
|
||||||
font-size: 9pt;
|
font-size: 8pt;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
th {
|
th {
|
||||||
background-color: #e9ecef;
|
background-color: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.text-left { text-align: left; }
|
.text-left { text-align: left; }
|
||||||
.text-right { text-align: right; }
|
.text-right { text-align: right; }
|
||||||
.header-info, .summary-info { font-size: 10pt; }
|
.header-info, .summary-info { font-size: 9pt; }
|
||||||
tr { page-break-inside: avoid; }
|
tr { page-break-inside: avoid; }
|
||||||
thead { display: table-header-group; }
|
thead { display: table-header-group; }
|
||||||
}
|
}
|
||||||
@@ -81,7 +85,7 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
|
|||||||
.a4-page {
|
.a4-page {
|
||||||
width: 21cm;
|
width: 21cm;
|
||||||
min-height: 29.7cm;
|
min-height: 29.7cm;
|
||||||
padding: 1.5cm;
|
padding: 1cm;
|
||||||
margin: 1cm auto;
|
margin: 1cm auto;
|
||||||
background: white;
|
background: white;
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||||
@@ -91,44 +95,43 @@ export const RelatorioPecasProcessoPrint: React.FC<RelatorioPecasProcessoPrintPr
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
th, td {
|
th, td {
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #e5e7eb;
|
||||||
padding: 4px 8px;
|
padding: 3px 6px;
|
||||||
font-size: 9pt;
|
font-size: 8pt;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
th {
|
th {
|
||||||
background-color: #e9ecef;
|
background-color: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.text-left { text-align: left; }
|
.text-left { text-align: left; }
|
||||||
.text-right { text-align: right; }
|
.text-right { text-align: right; }
|
||||||
.header-info, .summary-info { font-size: 10pt; }
|
.header-info, .summary-info { font-size: 9pt; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="a4-page">
|
<div class="a4-page">
|
||||||
<!-- Cabeçalho -->
|
<!-- Cabeçalho -->
|
||||||
<header style="margin-bottom: 16px;">
|
<header style="margin-bottom: 12px;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid #ccc; padding-bottom: 8px;">
|
<div style="display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 1px solid #ccc; padding-bottom: 4px;">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="font-size: 20px; font-weight: bold; color: #1f2937; margin: 0 0 4px 0;">Relatório de Status</h1>
|
<h1 style="font-size: 18px; font-weight: bold; color: #1f2937; margin: 0 0 2px 0;">Relatório de Status de Produção</h1>
|
||||||
<p style="font-size: 14px; color: #6b7280; margin: 0;" class="header-info">Ordem de Fabricação: <span style="font-weight: 600;">${selectedOF}${faseText}</span></p>
|
<p style="font-size: 12px; color: #6b7280; margin: 0;" class="header-info">OF: <span style="font-weight: 600;">${selectedOF}${faseText}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align: right;">
|
<div style="text-align: right;">
|
||||||
<p style="font-size: 14px; color: #6b7280; margin: 0;" class="header-info">Gerado em:</p>
|
<p style="font-size: 11px; color: #9ca3af; margin: 0;" class="header-info">Gerado em: ${currentDate}</p>
|
||||||
<p style="font-size: 14px; color: #9ca3af; margin: 0;" class="header-info">${currentDate}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Resumo -->
|
<!-- Resumo -->
|
||||||
<section style="margin-bottom: 16px;">
|
<section style="margin-bottom: 12px;">
|
||||||
<h2 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #374151;">Resumo por Processo</h2>
|
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; font-size: 11px; color: #4b5563; padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 6px; background-color: #f9fafb;" class="summary-info">
|
||||||
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; font-size: 12px; color: #6b7280; padding: 8px; border: 1px solid #e5e7eb; border-radius: 8px; background-color: #f9fafb;" class="summary-info">
|
<div><strong>Total Peças:</strong> ${estatisticas.totalPecas}</div>
|
||||||
<div><strong>Total de Peças:</strong> <span style="font-family: monospace;">${estatisticas.totalPecas}</span></div>
|
<div><strong>Corte:</strong> ${estatisticas.pesoTotalCorte.toFixed(0)} kg</div>
|
||||||
<div><strong>Peso Corte:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalCorte.toFixed(2)} kg</span></div>
|
<div><strong>Solda:</strong> ${estatisticas.pesoTotalSolda.toFixed(0)} kg</div>
|
||||||
<div><strong>Peso Solda:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalSolda.toFixed(2)} kg</span></div>
|
<div><strong>Pintura:</strong> ${estatisticas.pesoTotalPintura.toFixed(0)} kg</div>
|
||||||
<div><strong>Peso Pintura:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalPintura.toFixed(2)} kg</span></div>
|
<div><strong>Expedição:</strong> ${estatisticas.pesoTotalExpedicao.toFixed(0)} kg</div>
|
||||||
<div><strong>Peso Expedição:</strong> <span style="font-family: monospace;">${estatisticas.pesoTotalExpedicao.toFixed(2)} kg</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import { UserProfile, UserFunction, UserPrivilege } from '@/hooks/useUserManagem
|
|||||||
import { AvatarUpload } from '@/components/ui/avatar-upload';
|
import { AvatarUpload } from '@/components/ui/avatar-upload';
|
||||||
import { useProfileImage } from '@/hooks/useProfileImage';
|
import { useProfileImage } from '@/hooks/useProfileImage';
|
||||||
import { usePasswordManagement } from '@/hooks/usePasswordManagement';
|
import { usePasswordManagement } from '@/hooks/usePasswordManagement';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { useUserManagement } from '@/hooks/useUserManagement';
|
||||||
|
import { Eye, EyeOff, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
interface UserModalProps {
|
interface UserModalProps {
|
||||||
user: UserProfile | null;
|
user: UserProfile | null;
|
||||||
@@ -35,7 +36,9 @@ interface UserModalProps {
|
|||||||
export function UserModal({ user, functions, privileges, onSave, onCreate, onClose, readOnly = false }: UserModalProps) {
|
export function UserModal({ user, functions, privileges, onSave, onCreate, onClose, readOnly = false }: UserModalProps) {
|
||||||
const { updateProfileImage, removeProfileImage, updating } = useProfileImage();
|
const { updateProfileImage, removeProfileImage, updating } = useProfileImage();
|
||||||
const { changeUserPassword, isChangingPassword } = usePasswordManagement();
|
const { changeUserPassword, isChangingPassword } = usePasswordManagement();
|
||||||
|
const { deleteUserAndLogtoAccount } = useUserManagement();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isDeletingLogto, setIsDeletingLogto] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
full_name: '',
|
full_name: '',
|
||||||
@@ -126,6 +129,18 @@ export function UserModal({ user, functions, privileges, onSave, onCreate, onClo
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteLogtoAndSupabase = async () => {
|
||||||
|
if (!user || isReadOnly) return;
|
||||||
|
if (confirm(`ATENÇÃO! Esta ação irá apagar a conta do usuário ${user.email} permanentemente no LOGTO e no Supabase. O usuário perderá o acesso totalmente. Tem certeza?`)) {
|
||||||
|
setIsDeletingLogto(true);
|
||||||
|
const success = await deleteUserAndLogtoAccount(user.id, user.email);
|
||||||
|
setIsDeletingLogto(false);
|
||||||
|
if (success) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-md">
|
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-md">
|
||||||
@@ -297,11 +312,23 @@ export function UserModal({ user, functions, privileges, onSave, onCreate, onClo
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
disabled={updating || (isCreateMode && !formData.email)}
|
disabled={updating || (isCreateMode && !formData.email) || isDeletingLogto}
|
||||||
>
|
>
|
||||||
{isCreateMode ? 'Criar Usuário' : 'Salvar'}
|
{isCreateMode ? 'Criar Usuário' : 'Salvar'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!isCreateMode && !isReadOnly && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteLogtoAndSupabase}
|
||||||
|
disabled={isDeletingLogto}
|
||||||
|
className="flex items-center gap-1 bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{isDeletingLogto ? 'Excluindo...' : 'Excluir Conta Logto'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button type="button" variant="ghost" onClick={onClose}>
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
{isReadOnly ? 'Fechar' : 'Cancelar'}
|
{isReadOnly ? 'Fechar' : 'Cancelar'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export const routeConfig: RouteDefinition[] = [
|
|||||||
component: () => import('@/pages/Auth'),
|
component: () => import('@/pages/Auth'),
|
||||||
guard: 'public',
|
guard: 'public',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/callback',
|
||||||
|
component: () => import('@/pages/Callback'),
|
||||||
|
guard: 'public',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Dashboard ────────────────────────────────────────────
|
// ── Dashboard ────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
@@ -224,13 +229,13 @@ export const routeConfig: RouteDefinition[] = [
|
|||||||
{
|
{
|
||||||
path: '/configuracoes',
|
path: '/configuracoes',
|
||||||
component: () => import('@/pages/Configuracoes'),
|
component: () => import('@/pages/Configuracoes'),
|
||||||
guard: 'resource',
|
guard: 'admin',
|
||||||
resourceKey: 'configuracoes-gerais',
|
resourceKey: 'configuracoes-gerais',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/theme-customization',
|
path: '/admin/theme-customization',
|
||||||
component: () => import('@/pages/ThemeCustomizationPage'),
|
component: () => import('@/pages/ThemeCustomizationPage'),
|
||||||
guard: 'resource',
|
guard: 'admin',
|
||||||
resourceKey: 'theme-customization',
|
resourceKey: 'theme-customization',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -86,9 +86,9 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
|
|||||||
data_apontamento,
|
data_apontamento,
|
||||||
quantidade_produzida,
|
quantidade_produzida,
|
||||||
tipo_apontamento,
|
tipo_apontamento,
|
||||||
processo:processos_fabricacao(nome, ordem),
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
|
||||||
peca:pecas(peso_unitario),
|
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
|
||||||
componente:componentes_peca(peso_unitario)
|
componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario)
|
||||||
`)
|
`)
|
||||||
.gte('data_apontamento', dataInicio)
|
.gte('data_apontamento', dataInicio)
|
||||||
.lte('data_apontamento', dataFim)
|
.lte('data_apontamento', dataFim)
|
||||||
@@ -216,6 +216,7 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [periodo]);
|
}, [periodo]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -84,16 +84,8 @@ export const useApontamentosProducao = () => {
|
|||||||
console.log('🔍 Iniciando busca COMPLETA de apontamentos...');
|
console.log('🔍 Iniciando busca COMPLETA de apontamentos...');
|
||||||
|
|
||||||
// Verificar se o usuário está autenticado
|
// Verificar se o usuário está autenticado
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
||||||
if (authError) {
|
|
||||||
console.error('❌ Erro de autenticação:', authError);
|
|
||||||
toast.error('Erro de autenticação: ' + authError.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.warn('⚠️ Usuário não autenticado');
|
console.warn('⚠️ Usuário não autenticado em useApontamentosProducao');
|
||||||
toast.error('Usuário não autenticado');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +113,7 @@ export const useApontamentosProducao = () => {
|
|||||||
console.log(`📊 TOTAL DE REGISTROS NA TABELA: ${totalCount}`);
|
console.log(`📊 TOTAL DE REGISTROS NA TABELA: ${totalCount}`);
|
||||||
|
|
||||||
// SEGUNDA VERIFICAÇÃO: Buscar TODOS os apontamentos sem qualquer limitação
|
// SEGUNDA VERIFICAÇÃO: Buscar TODOS os apontamentos sem qualquer limitação
|
||||||
let allApontamentos: any[] = [];
|
let allApontamentos: ApontamentoProducao[] = [];
|
||||||
let pageNumber: number = 0;
|
let pageNumber: number = 0;
|
||||||
const itemsPerPage: number = 1000; // Buscar em lotes de 1000 para evitar timeout
|
const itemsPerPage: number = 1000; // Buscar em lotes de 1000 para evitar timeout
|
||||||
let hasMoreData: boolean = true;
|
let hasMoreData: boolean = true;
|
||||||
@@ -137,9 +129,9 @@ export const useApontamentosProducao = () => {
|
|||||||
.from('apontamentos_producao')
|
.from('apontamentos_producao')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
peca:pecas(marca, descricao, peso_unitario, etapa_fase),
|
peca:pecas!apontamentos_producao_peca_id_fkey(marca, descricao, peso_unitario, etapa_fase),
|
||||||
processo:processos_fabricacao(nome, ordem),
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
|
||||||
componente:componentes_peca(marca_componente, descricao, peso_unitario)
|
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente, descricao, peso_unitario)
|
||||||
`)
|
`)
|
||||||
.range(startIndex, endIndex)
|
.range(startIndex, endIndex)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
@@ -156,7 +148,7 @@ export const useApontamentosProducao = () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
allApontamentos = [...allApontamentos, ...pageData];
|
allApontamentos = [...allApontamentos, ...(pageData as unknown as ApontamentoProducao[])];
|
||||||
console.log(`✅ Página ${pageNumber + 1}: ${pageData.length} registros carregados. Total acumulado: ${allApontamentos.length}`);
|
console.log(`✅ Página ${pageNumber + 1}: ${pageData.length} registros carregados. Total acumulado: ${allApontamentos.length}`);
|
||||||
|
|
||||||
// Se retornou menos que o itemsPerPage, chegamos ao fim
|
// Se retornou menos que o itemsPerPage, chegamos ao fim
|
||||||
@@ -220,7 +212,7 @@ export const useApontamentosProducao = () => {
|
|||||||
toast.error('Erro desconhecido ao carregar apontamentos');
|
toast.error('Erro desconhecido ao carregar apontamentos');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [user]);
|
||||||
|
|
||||||
const fetchProcessos = useCallback(async () => {
|
const fetchProcessos = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -325,7 +317,19 @@ export const useApontamentosProducao = () => {
|
|||||||
|
|
||||||
console.log('🔄 Criando apontamento:', apontamento);
|
console.log('🔄 Criando apontamento:', apontamento);
|
||||||
|
|
||||||
const insertData: any = {
|
interface InsertApontamentoData {
|
||||||
|
of_number: string;
|
||||||
|
tipo_apontamento: 'peca' | 'componente';
|
||||||
|
processo_id: string;
|
||||||
|
quantidade_produzida: number;
|
||||||
|
data_apontamento: string;
|
||||||
|
observacoes?: string | null;
|
||||||
|
created_by?: string;
|
||||||
|
peca_id?: string | null;
|
||||||
|
componente_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertData: InsertApontamentoData = {
|
||||||
of_number: apontamento.of_number,
|
of_number: apontamento.of_number,
|
||||||
tipo_apontamento: apontamento.tipo_apontamento,
|
tipo_apontamento: apontamento.tipo_apontamento,
|
||||||
processo_id: apontamento.processo_id,
|
processo_id: apontamento.processo_id,
|
||||||
@@ -377,9 +381,13 @@ export const useApontamentosProducao = () => {
|
|||||||
toast.success('Apontamento registrado com sucesso!');
|
toast.success('Apontamento registrado com sucesso!');
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('❌ Erro ao criar apontamento:', error);
|
console.error('❌ Erro ao criar apontamento:', error);
|
||||||
toast.error('Erro ao registrar apontamento: ' + (error as any).message);
|
const isConflict = error?.status === 409 || error?.code === '23505' || String(error?.message || '').includes('409');
|
||||||
|
const errorMessage = isConflict
|
||||||
|
? 'Este item ou processo já teve o apontamento registrado por outro usuário ou os dados em cache estão desatualizados. Por favor, clique em "Limpar" no cache e atualize a página.'
|
||||||
|
: (error instanceof Error ? error.message : String(error));
|
||||||
|
toast.error('Erro ao registrar apontamento: ' + errorMessage);
|
||||||
return { success: false, error };
|
return { success: false, error };
|
||||||
}
|
}
|
||||||
}, [user, fetchApontamentos]);
|
}, [user, fetchApontamentos]);
|
||||||
|
|||||||
@@ -203,9 +203,9 @@ export const useApontamentosProducaoOtimizado = () => {
|
|||||||
.from('apontamentos_producao')
|
.from('apontamentos_producao')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
peca:pecas(marca, descricao),
|
peca:pecas!apontamentos_producao_peca_id_fkey(marca, descricao),
|
||||||
processo:processos_fabricacao(nome, ordem),
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
|
||||||
componente:componentes_peca(marca_componente, descricao)
|
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente, descricao)
|
||||||
`)
|
`)
|
||||||
.eq('of_number', ofNumber)
|
.eq('of_number', ofNumber)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ export const useAuditoriaInconsistencias = () => {
|
|||||||
.from('apontamentos_producao')
|
.from('apontamentos_producao')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
processo:processos_fabricacao(nome, ordem),
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem),
|
||||||
peca:pecas(marca, etapa_fase, quantidade),
|
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase, quantidade),
|
||||||
componente:componentes_peca(marca_componente)
|
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente)
|
||||||
`)
|
`)
|
||||||
.eq('of_number', ofNumber)
|
.eq('of_number', ofNumber)
|
||||||
.then(({ data }) => data || []),
|
.then(({ data }) => data || []),
|
||||||
|
|||||||
+70
-2
@@ -7,8 +7,10 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useRef,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import {
|
import {
|
||||||
LogtoUser,
|
LogtoUser,
|
||||||
signIn as logtoSignIn,
|
signIn as logtoSignIn,
|
||||||
@@ -19,6 +21,52 @@ import {
|
|||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
} from '@/lib/logto/client';
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseAuthReturn {
|
export interface UseAuthReturn {
|
||||||
user: LogtoUser | null;
|
user: LogtoUser | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -28,6 +76,7 @@ export interface UseAuthReturn {
|
|||||||
signUp: (email: string, password: string) => Promise<{ error: unknown }>;
|
signUp: (email: string, password: string) => Promise<{ error: unknown }>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
updatePassword: (password: string) => Promise<{ error: unknown }>;
|
updatePassword: (password: string) => Promise<{ error: unknown }>;
|
||||||
|
handleCallback: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<UseAuthReturn | undefined>(undefined);
|
const AuthContext = createContext<UseAuthReturn | undefined>(undefined);
|
||||||
@@ -37,15 +86,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [authInitialized, setAuthInitialized] = useState(false);
|
const [authInitialized, setAuthInitialized] = useState(false);
|
||||||
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
|
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
|
||||||
|
const callbackHandled = useRef(false);
|
||||||
|
|
||||||
// Detecta callback URL e processa
|
// Detecta callback URL e processa
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
if (url.searchParams.has('code')) {
|
if (url.searchParams.has('code')) {
|
||||||
|
if (callbackHandled.current) return;
|
||||||
|
callbackHandled.current = true;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
handleCallback().then((ok) => {
|
handleCallback().then((ok) => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
logtoGetUser().then(setUser);
|
logtoGetUser().then(async (u) => {
|
||||||
|
if (u) {
|
||||||
|
const profileId = await syncUserToProfile(u);
|
||||||
|
u.id = profileId;
|
||||||
|
}
|
||||||
|
setUser(u);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setAuthInitialized(true);
|
setAuthInitialized(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -53,7 +112,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
} else {
|
} else {
|
||||||
setAuthInitialized(true);
|
setAuthInitialized(true);
|
||||||
if (isAuthenticated()) {
|
if (isAuthenticated()) {
|
||||||
logtoGetUser().then((u) => {
|
logtoGetUser().then(async (u) => {
|
||||||
|
if (u) {
|
||||||
|
const profileId = await syncUserToProfile(u);
|
||||||
|
u.id = profileId;
|
||||||
|
}
|
||||||
setUser(u);
|
setUser(u);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
@@ -92,6 +155,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleCallbackFn = useCallback(async (): Promise<boolean> => {
|
||||||
|
return await handleCallback();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value: UseAuthReturn = {
|
const value: UseAuthReturn = {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
@@ -101,6 +168,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
signUp,
|
signUp,
|
||||||
signOut,
|
signOut,
|
||||||
updatePassword,
|
updatePassword,
|
||||||
|
handleCallback: handleCallbackFn,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
@@ -1,249 +1 @@
|
|||||||
|
export { useCronogramas as useCronogramaOperations } from './useCronogramas';
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useAuth } from './useAuth';
|
|
||||||
import { CronogramaOf } from '@/types/cronograma';
|
|
||||||
|
|
||||||
export const useCronogramaOperations = () => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [cronogramas, setCronogramas] = useState<CronogramaOf[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const loadCronogramas = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const { data: cronogramasData, error: cronogramasError } = await supabase
|
|
||||||
.from('cronogramas_of')
|
|
||||||
.select(`
|
|
||||||
*,
|
|
||||||
ordens_fabricacao (
|
|
||||||
num_of,
|
|
||||||
descritivo
|
|
||||||
),
|
|
||||||
profiles!cronogramas_of_gestor_id_fkey (
|
|
||||||
full_name,
|
|
||||||
email
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (cronogramasError) throw cronogramasError;
|
|
||||||
|
|
||||||
// Carregar processos para cada cronograma
|
|
||||||
const cronogramasComProcessos = await Promise.all(
|
|
||||||
(cronogramasData || []).map(async (cronograma) => {
|
|
||||||
const { data: processos, error: processosError } = await supabase
|
|
||||||
.from('processos_cronograma')
|
|
||||||
.select('*')
|
|
||||||
.eq('cronograma_id', cronograma.id)
|
|
||||||
.order('ordem');
|
|
||||||
|
|
||||||
if (processosError) throw processosError;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: cronograma.id,
|
|
||||||
of_id: cronograma.of_id,
|
|
||||||
gestor_id: cronograma.gestor_id,
|
|
||||||
revisao: cronograma.revisao,
|
|
||||||
processos: processos || [],
|
|
||||||
ordem_fabricacao: cronograma.ordens_fabricacao,
|
|
||||||
gestor_profile: cronograma.profiles
|
|
||||||
} as CronogramaOf;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setCronogramas(cronogramasComProcessos);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao carregar cronogramas:', error);
|
|
||||||
toast.error('Erro ao carregar cronogramas');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveCronograma = async (cronograma: Partial<CronogramaOf> & { processos: any[] }) => {
|
|
||||||
try {
|
|
||||||
if (!user) throw new Error('Usuário não autenticado');
|
|
||||||
|
|
||||||
let cronogramaId: string;
|
|
||||||
|
|
||||||
if (cronograma.id) {
|
|
||||||
// Atualizar cronograma existente
|
|
||||||
const novaRevisao = cronograma.revisao ? cronograma.revisao + 1 : 1;
|
|
||||||
|
|
||||||
const { data: updatedCronograma, error: updateError } = await supabase
|
|
||||||
.from('cronogramas_of')
|
|
||||||
.update({
|
|
||||||
gestor_id: cronograma.gestor_id,
|
|
||||||
revisao: novaRevisao,
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.eq('id', cronograma.id)
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (updateError) throw updateError;
|
|
||||||
cronogramaId = updatedCronograma.id;
|
|
||||||
|
|
||||||
// Deletar processos antigos
|
|
||||||
const { error: deleteError } = await supabase
|
|
||||||
.from('processos_cronograma')
|
|
||||||
.delete()
|
|
||||||
.eq('cronograma_id', cronogramaId);
|
|
||||||
|
|
||||||
if (deleteError) throw deleteError;
|
|
||||||
} else {
|
|
||||||
// Verificar se já existe cronograma para esta OF
|
|
||||||
const { data: existingCronograma } = await supabase
|
|
||||||
.from('cronogramas_of')
|
|
||||||
.select('id, revisao')
|
|
||||||
.eq('of_id', cronograma.of_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (existingCronograma) {
|
|
||||||
// Atualizar cronograma existente
|
|
||||||
const novaRevisao = existingCronograma.revisao + 1;
|
|
||||||
|
|
||||||
const { data: updatedCronograma, error: updateError } = await supabase
|
|
||||||
.from('cronogramas_of')
|
|
||||||
.update({
|
|
||||||
gestor_id: cronograma.gestor_id,
|
|
||||||
revisao: novaRevisao,
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.eq('id', existingCronograma.id)
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (updateError) throw updateError;
|
|
||||||
cronogramaId = updatedCronograma.id;
|
|
||||||
|
|
||||||
// Deletar processos antigos
|
|
||||||
const { error: deleteError } = await supabase
|
|
||||||
.from('processos_cronograma')
|
|
||||||
.delete()
|
|
||||||
.eq('cronograma_id', cronogramaId);
|
|
||||||
|
|
||||||
if (deleteError) throw deleteError;
|
|
||||||
} else {
|
|
||||||
// Criar novo cronograma
|
|
||||||
const { data: newCronograma, error: insertError } = await supabase
|
|
||||||
.from('cronogramas_of')
|
|
||||||
.insert({
|
|
||||||
of_id: cronograma.of_id,
|
|
||||||
gestor_id: cronograma.gestor_id,
|
|
||||||
revisao: 1,
|
|
||||||
created_by: user.id
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (insertError) throw insertError;
|
|
||||||
cronogramaId = newCronograma.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inserir novos processos
|
|
||||||
const processosParaInserir = cronograma.processos.map(processo => ({
|
|
||||||
cronograma_id: cronogramaId,
|
|
||||||
nome_processo: processo.nome_processo,
|
|
||||||
data_inicio: processo.data_inicio,
|
|
||||||
data_fim: processo.data_fim,
|
|
||||||
ordem: processo.ordem
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { error: processosError } = await supabase
|
|
||||||
.from('processos_cronograma')
|
|
||||||
.insert(processosParaInserir);
|
|
||||||
|
|
||||||
if (processosError) throw processosError;
|
|
||||||
|
|
||||||
toast.success('Cronograma salvo com sucesso!');
|
|
||||||
loadCronogramas();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao salvar cronograma:', error);
|
|
||||||
toast.error('Erro ao salvar cronograma');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteCronograma = async (cronogramaId: string) => {
|
|
||||||
try {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('cronogramas_of')
|
|
||||||
.delete()
|
|
||||||
.eq('id', cronogramaId);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
toast.success('Cronograma removido com sucesso!');
|
|
||||||
loadCronogramas();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao deletar cronograma:', error);
|
|
||||||
toast.error('Erro ao remover cronograma');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCronogramaPorOf = async (ofId: string) => {
|
|
||||||
try {
|
|
||||||
const { data: cronograma, error } = await supabase
|
|
||||||
.from('cronogramas_of')
|
|
||||||
.select(`
|
|
||||||
*,
|
|
||||||
ordens_fabricacao (
|
|
||||||
num_of,
|
|
||||||
descritivo
|
|
||||||
),
|
|
||||||
profiles!cronogramas_of_gestor_id_fkey (
|
|
||||||
full_name,
|
|
||||||
email
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.eq('of_id', ofId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error && error.code !== 'PGRST116') throw error;
|
|
||||||
|
|
||||||
if (!cronograma) return null;
|
|
||||||
|
|
||||||
const { data: processos, error: processosError } = await supabase
|
|
||||||
.from('processos_cronograma')
|
|
||||||
.select('*')
|
|
||||||
.eq('cronograma_id', cronograma.id)
|
|
||||||
.order('ordem');
|
|
||||||
|
|
||||||
if (processosError) throw processosError;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: cronograma.id,
|
|
||||||
of_id: cronograma.of_id,
|
|
||||||
gestor_id: cronograma.gestor_id,
|
|
||||||
revisao: cronograma.revisao,
|
|
||||||
processos: processos || [],
|
|
||||||
ordem_fabricacao: cronograma.ordens_fabricacao,
|
|
||||||
gestor_profile: cronograma.profiles
|
|
||||||
} as CronogramaOf;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao buscar cronograma:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadCronogramas();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
cronogramas,
|
|
||||||
loading,
|
|
||||||
loadCronogramas,
|
|
||||||
saveCronograma,
|
|
||||||
deleteCronograma,
|
|
||||||
getCronogramaPorOf
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const useCronogramas = () => {
|
|||||||
descritivo,
|
descritivo,
|
||||||
peso_total
|
peso_total
|
||||||
),
|
),
|
||||||
profiles!gestor_id (
|
profiles!cronogramas_of_gestor_id_fkey (
|
||||||
full_name,
|
full_name,
|
||||||
email
|
email
|
||||||
)
|
)
|
||||||
@@ -89,23 +89,50 @@ export const useCronogramas = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveCronograma = async (cronograma: Omit<CronogramaOf, 'id'>) => {
|
const saveCronograma = async (cronograma: Partial<CronogramaOf> & { processos: ProcessoCronograma[] }) => {
|
||||||
try {
|
try {
|
||||||
if (!user) throw new Error('Usuário não autenticado');
|
if (!user) throw new Error('Usuário não autenticado');
|
||||||
|
if (!cronograma.of_id) throw new Error('Selecione uma Ordem de Fabricação');
|
||||||
|
if (!cronograma.gestor_id) throw new Error('Selecione o gestor responsável');
|
||||||
|
|
||||||
// Verificar se já existe cronograma para esta OF
|
let cronogramaId: string;
|
||||||
|
|
||||||
|
if (cronograma.id) {
|
||||||
|
// Atualizar cronograma existente por ID
|
||||||
|
const novaRevisao = cronograma.revisao ? cronograma.revisao + 1 : 1;
|
||||||
|
|
||||||
|
const { data: updatedCronograma, error: updateError } = await supabase
|
||||||
|
.from('cronogramas_of')
|
||||||
|
.update({
|
||||||
|
gestor_id: cronograma.gestor_id,
|
||||||
|
revisao: novaRevisao,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', cronograma.id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
cronogramaId = updatedCronograma.id;
|
||||||
|
|
||||||
|
// Deletar processos antigos
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('processos_cronograma')
|
||||||
|
.delete()
|
||||||
|
.eq('cronograma_id', cronogramaId);
|
||||||
|
|
||||||
|
if (deleteError) throw deleteError;
|
||||||
|
} else {
|
||||||
|
// Verificar se já existe cronograma para esta OF (usando maybeSingle para evitar erro PGRST116)
|
||||||
const { data: existingCronograma } = await supabase
|
const { data: existingCronograma } = await supabase
|
||||||
.from('cronogramas_of')
|
.from('cronogramas_of')
|
||||||
.select('id, revisao')
|
.select('id, revisao')
|
||||||
.eq('of_id', cronograma.of_id)
|
.eq('of_id', cronograma.of_id)
|
||||||
.single();
|
.maybeSingle();
|
||||||
|
|
||||||
let cronogramaId: string;
|
|
||||||
let novaRevisao = 1;
|
|
||||||
|
|
||||||
if (existingCronograma) {
|
if (existingCronograma) {
|
||||||
// Atualizar cronograma existente
|
// Atualizar cronograma existente
|
||||||
novaRevisao = existingCronograma.revisao + 1;
|
const novaRevisao = (existingCronograma.revisao || 1) + 1;
|
||||||
|
|
||||||
const { data: updatedCronograma, error: updateError } = await supabase
|
const { data: updatedCronograma, error: updateError } = await supabase
|
||||||
.from('cronogramas_of')
|
.from('cronogramas_of')
|
||||||
@@ -144,14 +171,16 @@ export const useCronogramas = () => {
|
|||||||
if (insertError) throw insertError;
|
if (insertError) throw insertError;
|
||||||
cronogramaId = newCronograma.id;
|
cronogramaId = newCronograma.id;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Inserir novos processos
|
// Inserir novos processos
|
||||||
const processosParaInserir = cronograma.processos.map(processo => ({
|
if (cronograma.processos && cronograma.processos.length > 0) {
|
||||||
|
const processosParaInserir = cronograma.processos.map((processo, idx) => ({
|
||||||
cronograma_id: cronogramaId,
|
cronograma_id: cronogramaId,
|
||||||
nome_processo: processo.nome_processo,
|
nome_processo: processo.nome_processo,
|
||||||
data_inicio: processo.data_inicio,
|
data_inicio: processo.data_inicio,
|
||||||
data_fim: processo.data_fim,
|
data_fim: processo.data_fim,
|
||||||
ordem: processo.ordem
|
ordem: processo.ordem || idx + 1
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { error: processosError } = await supabase
|
const { error: processosError } = await supabase
|
||||||
@@ -159,13 +188,15 @@ export const useCronogramas = () => {
|
|||||||
.insert(processosParaInserir);
|
.insert(processosParaInserir);
|
||||||
|
|
||||||
if (processosError) throw processosError;
|
if (processosError) throw processosError;
|
||||||
|
}
|
||||||
|
|
||||||
toast.success('Cronograma salvo com sucesso!');
|
toast.success('Cronograma salvo com sucesso!');
|
||||||
loadCronogramas();
|
await loadCronogramas();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao salvar cronograma:', error);
|
console.error('Erro ao salvar cronograma:', error);
|
||||||
toast.error('Erro ao salvar cronograma');
|
const errMsg = error instanceof Error ? error.message : 'Erro ao salvar cronograma';
|
||||||
|
toast.error(errMsg);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -180,7 +211,7 @@ export const useCronogramas = () => {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success('Cronograma removido com sucesso!');
|
toast.success('Cronograma removido com sucesso!');
|
||||||
loadCronogramas();
|
await loadCronogramas();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao deletar cronograma:', error);
|
console.error('Erro ao deletar cronograma:', error);
|
||||||
@@ -200,16 +231,15 @@ export const useCronogramas = () => {
|
|||||||
descritivo,
|
descritivo,
|
||||||
peso_total
|
peso_total
|
||||||
),
|
),
|
||||||
profiles!gestor_id (
|
profiles!cronogramas_of_gestor_id_fkey (
|
||||||
full_name,
|
full_name,
|
||||||
email
|
email
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('of_id', ofId)
|
.eq('of_id', ofId)
|
||||||
.single();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error && error.code !== 'PGRST116') throw error;
|
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
if (!cronograma) return null;
|
if (!cronograma) return null;
|
||||||
|
|
||||||
const { data: processos, error: processosError } = await supabase
|
const { data: processos, error: processosError } = await supabase
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export const useDashboardProducao = (ofNumber: string) => {
|
|||||||
.from('apontamentos_producao')
|
.from('apontamentos_producao')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
peca:pecas(peso_unitario),
|
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
|
||||||
processo:processos_fabricacao(nome, ordem)
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem)
|
||||||
`)
|
`)
|
||||||
.eq('of_number', ofNumber);
|
.eq('of_number', ofNumber);
|
||||||
|
|
||||||
@@ -172,6 +172,7 @@ export const useDashboardProducao = (ofNumber: string) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [ofNumber]);
|
}, [ofNumber]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
|||||||
const [dashboardData, setDashboardData] = useState<DashboardDataOtimizado | null>(null);
|
const [dashboardData, setDashboardData] = useState<DashboardDataOtimizado | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const fetchDashboardData = async () => {
|
const fetchDashboardData = useCallback(async () => {
|
||||||
if (!ofNumber) {
|
if (!ofNumber) {
|
||||||
setDashboardData(null);
|
setDashboardData(null);
|
||||||
return;
|
return;
|
||||||
@@ -102,6 +102,26 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CORREÇÃO AUTOMÁTICA: Evita que erros de digitação (ex: digitar 1.856 ao invés de 1856 kg na OF)
|
||||||
|
// causem porcentagens irreais (ex: 100000%). Compara com a soma real das peças.
|
||||||
|
if (pesoTotalPlanejado > 0) {
|
||||||
|
const { data: pecasVerificar } = await supabase.from('pecas').select('peso_unitario, quantidade').eq('of_number', ofNumber);
|
||||||
|
if (pecasVerificar && pecasVerificar.length > 0) {
|
||||||
|
const pesoBrutoPecas = pecasVerificar.reduce((sum, p) => sum + ((p.peso_unitario || 0) * (p.quantidade || 0)), 0);
|
||||||
|
|
||||||
|
if (pesoBrutoPecas > pesoTotalPlanejado * 50) {
|
||||||
|
console.log(`Corrigindo erro de digitação de peso da OF. Planejado original: ${pesoTotalPlanejado} kg, Soma das peças: ${pesoBrutoPecas} kg`);
|
||||||
|
// Se for um erro típico de 1000x (usou ponto para separar milhar)
|
||||||
|
if (pesoBrutoPecas <= pesoTotalPlanejado * 3000 && pesoBrutoPecas >= pesoTotalPlanejado * 500) {
|
||||||
|
pesoTotalPlanejado = pesoTotalPlanejado * 1000;
|
||||||
|
} else {
|
||||||
|
// Se for outro erro grosseiro, assume que o peso correto é o das peças
|
||||||
|
pesoTotalPlanejado = pesoBrutoPecas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Peso total planejado (BASE DE TODOS OS CÁLCULOS):', pesoTotalPlanejado, 'kg');
|
console.log('Peso total planejado (BASE DE TODOS OS CÁLCULOS):', pesoTotalPlanejado, 'kg');
|
||||||
|
|
||||||
// 4. Buscar apontamentos da OF com joins corretos
|
// 4. Buscar apontamentos da OF com joins corretos
|
||||||
@@ -109,9 +129,9 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
|||||||
.from('apontamentos_producao')
|
.from('apontamentos_producao')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
peca:pecas(peso_unitario, marca),
|
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario, marca),
|
||||||
processo:processos_fabricacao(nome, ordem, cor),
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, ordem, cor),
|
||||||
componente:componentes_peca(peso_unitario, marca_componente)
|
componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario, marca_componente)
|
||||||
`)
|
`)
|
||||||
.eq('of_number', ofNumber);
|
.eq('of_number', ofNumber);
|
||||||
|
|
||||||
@@ -158,20 +178,24 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
|||||||
|
|
||||||
console.log(`PROGRESSO GERAL: ${pesoTotalFabricado} kg / ${pesoTotalPlanejado} kg = ${progressoGeral.toFixed(2)}%`);
|
console.log(`PROGRESSO GERAL: ${pesoTotalFabricado} kg / ${pesoTotalPlanejado} kg = ${progressoGeral.toFixed(2)}%`);
|
||||||
|
|
||||||
// 7. Buscar range de datas
|
// 7. Buscar range de datas (opcional, ignora erro se a RPC não existir no Supabase)
|
||||||
const { data: dateRangeData, error: dateRangeError } = await supabase
|
let dateRangeData = null;
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
.rpc('get_dashboard_date_range', { of_number_param: ofNumber });
|
.rpc('get_dashboard_date_range', { of_number_param: ofNumber });
|
||||||
|
if (!error) dateRangeData = data;
|
||||||
if (dateRangeError) {
|
} catch (e) {
|
||||||
console.error('Erro ao buscar range de datas:', dateRangeError);
|
// Ignora caso RPC não exista
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Buscar dados consolidados
|
// 8. Buscar dados consolidados (opcional, ignora erro se a RPC não existir no Supabase)
|
||||||
const { data: consolidatedData, error: consolidatedError } = await supabase
|
let consolidatedData = null;
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
.rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber });
|
.rpc('get_dashboard_consolidated_data', { of_number_param: ofNumber });
|
||||||
|
if (!error) consolidatedData = data;
|
||||||
if (consolidatedError) {
|
} catch (e) {
|
||||||
console.error('Erro ao buscar dados consolidados:', consolidatedError);
|
// Ignora caso RPC não exista
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Buscar processos
|
// 9. Buscar processos
|
||||||
@@ -207,7 +231,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
|||||||
if (!cronogramaOf?.processos_cronograma) return 0;
|
if (!cronogramaOf?.processos_cronograma) return 0;
|
||||||
|
|
||||||
const hoje = new Date();
|
const hoje = new Date();
|
||||||
let processoCronograma = null;
|
let processoCronograma;
|
||||||
|
|
||||||
// Mapear nomes dos processos para os do cronograma
|
// Mapear nomes dos processos para os do cronograma
|
||||||
if (nomeProcesso.toLowerCase().includes('corte') || nomeProcesso.toLowerCase().includes('solda')) {
|
if (nomeProcesso.toLowerCase().includes('corte') || nomeProcesso.toLowerCase().includes('solda')) {
|
||||||
@@ -291,7 +315,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
|||||||
const progressoEsperado = calcularProgressoEsperado(processo.nome);
|
const progressoEsperado = calcularProgressoEsperado(processo.nome);
|
||||||
|
|
||||||
// Determinar status baseado na comparação entre progresso real e esperado
|
// Determinar status baseado na comparação entre progresso real e esperado
|
||||||
let status: 'verde' | 'amarelo' | 'vermelho' | 'azul' = 'verde';
|
let status: 'verde' | 'amarelo' | 'vermelho' | 'azul';
|
||||||
|
|
||||||
// Se o progresso esperado chegou a 100% (passou da data fim), usar cor vermelha na barra
|
// Se o progresso esperado chegou a 100% (passou da data fim), usar cor vermelha na barra
|
||||||
if (progressoEsperado >= 100) {
|
if (progressoEsperado >= 100) {
|
||||||
@@ -330,7 +354,7 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
|||||||
let dataFimProcesso: Date | null = null;
|
let dataFimProcesso: Date | null = null;
|
||||||
|
|
||||||
if (cronogramaOf?.processos_cronograma) {
|
if (cronogramaOf?.processos_cronograma) {
|
||||||
let processoCronograma = null;
|
let processoCronograma;
|
||||||
|
|
||||||
// Mapear nomes dos processos para os do cronograma
|
// Mapear nomes dos processos para os do cronograma
|
||||||
if (processo.nome.toLowerCase().includes('corte') || processo.nome.toLowerCase().includes('solda')) {
|
if (processo.nome.toLowerCase().includes('corte') || processo.nome.toLowerCase().includes('solda')) {
|
||||||
@@ -406,7 +430,11 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
|||||||
// Antes do início planejado, o planejado fica 0
|
// Antes do início planejado, o planejado fica 0
|
||||||
|
|
||||||
// Calcular progresso real até esta data - soma dos pesos apontados
|
// Calcular progresso real até esta data - soma dos pesos apontados
|
||||||
const apontamentosAteData = apontamentosProcesso.filter(a => a.data_apontamento <= dataStr);
|
const apontamentosAteData = apontamentosProcesso.filter(a => {
|
||||||
|
if (!a.data_apontamento) return false;
|
||||||
|
const dataAptStr = typeof a.data_apontamento === 'string' ? a.data_apontamento.substring(0, 10) : '';
|
||||||
|
return dataAptStr <= dataStr;
|
||||||
|
});
|
||||||
let realizado = 0;
|
let realizado = 0;
|
||||||
apontamentosAteData.forEach(a => {
|
apontamentosAteData.forEach(a => {
|
||||||
let pesoUnitario = 0;
|
let pesoUnitario = 0;
|
||||||
@@ -524,11 +552,11 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [ofNumber]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
}, [ofNumber]);
|
}, [fetchDashboardData]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dashboardData,
|
dashboardData,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface FichaTecnicaData {
|
|||||||
doc_catalogo?: boolean;
|
doc_catalogo?: boolean;
|
||||||
doc_fotos?: boolean;
|
doc_fotos?: boolean;
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
// Informações do projeto
|
// Informações do projeto
|
||||||
info_calculo_estrutural?: any;
|
info_calculo_estrutural?: any;
|
||||||
info_projeto_basico?: any;
|
info_projeto_basico?: any;
|
||||||
@@ -144,6 +145,7 @@ export interface FichaTecnicaData {
|
|||||||
visto_exp?: string;
|
visto_exp?: string;
|
||||||
visto_qual?: string;
|
visto_qual?: string;
|
||||||
visto_colunas?: any;
|
visto_colunas?: any;
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFichaTecnica() {
|
export function useFichaTecnica() {
|
||||||
@@ -160,7 +162,6 @@ export function useFichaTecnica() {
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('ficha_tecnica_contratos')
|
.from('ficha_tecnica_contratos')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('user_id', user.id)
|
|
||||||
.order('of_number', { ascending: true });
|
.order('of_number', { ascending: true });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -182,7 +183,6 @@ export function useFichaTecnica() {
|
|||||||
.from('ficha_tecnica_contratos')
|
.from('ficha_tecnica_contratos')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('of_number', ofNumber.trim())
|
.eq('of_number', ofNumber.trim())
|
||||||
.eq('user_id', user.id)
|
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -373,8 +373,7 @@ export function useFichaTecnica() {
|
|||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('ficha_tecnica_contratos')
|
.from('ficha_tecnica_contratos')
|
||||||
.update(dataToSave)
|
.update(dataToSave)
|
||||||
.eq('id', data.id)
|
.eq('id', data.id);
|
||||||
.eq('user_id', user.id);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Erro ao atualizar ficha técnica:', error);
|
console.error('Erro ao atualizar ficha técnica:', error);
|
||||||
@@ -389,7 +388,6 @@ export function useFichaTecnica() {
|
|||||||
.from('ficha_tecnica_contratos')
|
.from('ficha_tecnica_contratos')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('of_number', data.of_number.trim())
|
.eq('of_number', data.of_number.trim())
|
||||||
.eq('user_id', user.id)
|
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (existingFicha) {
|
if (existingFicha) {
|
||||||
@@ -397,8 +395,7 @@ export function useFichaTecnica() {
|
|||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('ficha_tecnica_contratos')
|
.from('ficha_tecnica_contratos')
|
||||||
.update(dataToSave)
|
.update(dataToSave)
|
||||||
.eq('id', existingFicha.id)
|
.eq('id', existingFicha.id);
|
||||||
.eq('user_id', user.id);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Erro ao atualizar ficha técnica existente:', error);
|
console.error('Erro ao atualizar ficha técnica existente:', error);
|
||||||
|
|||||||
@@ -209,16 +209,17 @@ export function useInterfaceResources() {
|
|||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('privilege_id')
|
.select('privilege_id')
|
||||||
.eq('id', userId)
|
.eq('id', userId)
|
||||||
.single();
|
.limit(1);
|
||||||
|
|
||||||
if (profileError) throw profileError;
|
if (profileError) throw profileError;
|
||||||
|
|
||||||
if (!profile?.privilege_id) return [];
|
const priv = profile && profile.length > 0 ? profile[0].privilege_id : null;
|
||||||
|
if (!priv) return [];
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('privilege_interface_resources')
|
.from('privilege_interface_resources')
|
||||||
.select('resource_key')
|
.select('resource_key')
|
||||||
.eq('privilege_id', profile.privilege_id);
|
.eq('privilege_id', priv);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
|||||||
@@ -44,14 +44,14 @@ export const useItensPrioridadeFabricacao = () => {
|
|||||||
.from('itens_prioridade_fabricacao')
|
.from('itens_prioridade_fabricacao')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
peca:pecas(marca, descricao, peso_unitario, quantidade, tem_componentes, of_number, etapa_fase),
|
peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(marca, descricao, peso_unitario, quantidade, tem_componentes, of_number, etapa_fase),
|
||||||
prioridade_fabricacao:prioridades_fabricacao(
|
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(
|
||||||
of_number,
|
of_number,
|
||||||
etapa_fase,
|
etapa_fase,
|
||||||
revisao,
|
revisao,
|
||||||
data_ultima_modificacao,
|
data_ultima_modificacao,
|
||||||
modificado_por,
|
modificado_por,
|
||||||
prioridade_config:prioridades_config(codigo, nome, cor)
|
prioridade_config:prioridades_config!prioridades_fabricacao_prioridade_id_fkey(codigo, nome, cor)
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.order('ordem_fabricacao', { ascending: true });
|
.order('ordem_fabricacao', { ascending: true });
|
||||||
@@ -124,8 +124,8 @@ export const useItensPrioridadeFabricacao = () => {
|
|||||||
.from('itens_prioridade_fabricacao')
|
.from('itens_prioridade_fabricacao')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
peca:pecas(of_number, etapa_fase, marca),
|
peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(of_number, etapa_fase, marca),
|
||||||
prioridade_fabricacao:prioridades_fabricacao(of_number, etapa_fase)
|
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(of_number, etapa_fase)
|
||||||
`)
|
`)
|
||||||
.eq('id', itemId)
|
.eq('id', itemId)
|
||||||
.single();
|
.single();
|
||||||
@@ -215,8 +215,8 @@ export const useItensPrioridadeFabricacao = () => {
|
|||||||
.select(`
|
.select(`
|
||||||
id,
|
id,
|
||||||
prioridade_fabricacao_id,
|
prioridade_fabricacao_id,
|
||||||
peca:pecas(of_number, etapa_fase, marca),
|
peca:pecas!itens_prioridade_fabricacao_peca_id_fkey(of_number, etapa_fase, marca),
|
||||||
prioridade_fabricacao:prioridades_fabricacao(of_number, etapa_fase)
|
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(of_number, etapa_fase)
|
||||||
`)
|
`)
|
||||||
.in('id', idsParaRemover);
|
.in('id', idsParaRemover);
|
||||||
|
|
||||||
@@ -341,7 +341,7 @@ export const useItensPrioridadeFabricacao = () => {
|
|||||||
.from('itens_prioridade_fabricacao')
|
.from('itens_prioridade_fabricacao')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
prioridade_fabricacao:prioridades_fabricacao(
|
prioridade_fabricacao:prioridades_fabricacao!itens_prioridade_fabricacao_prioridade_fabricacao_id_fkey(
|
||||||
of_number,
|
of_number,
|
||||||
etapa_fase
|
etapa_fase
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
|
|||||||
.select(`
|
.select(`
|
||||||
revisao,
|
revisao,
|
||||||
data_ultima_modificacao,
|
data_ultima_modificacao,
|
||||||
profiles:modificado_por(full_name)
|
profiles:prioridades_fabricacao_modificado_por_profiles_fkey(full_name)
|
||||||
`)
|
`)
|
||||||
.eq('of_number', ofSelecionada)
|
.eq('of_number', ofSelecionada)
|
||||||
.eq('etapa_fase', faseSelecionada)
|
.eq('etapa_fase', faseSelecionada)
|
||||||
@@ -71,7 +71,7 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
|
|||||||
setVersaoAtual({
|
setVersaoAtual({
|
||||||
revisao: data.revisao || 0,
|
revisao: data.revisao || 0,
|
||||||
dataModificacao: data.data_ultima_modificacao || new Date().toISOString(),
|
dataModificacao: data.data_ultima_modificacao || new Date().toISOString(),
|
||||||
modificadoPor: (data.profiles as any)?.full_name
|
modificadoPor: (data.profiles as { full_name?: string } | null)?.full_name
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setVersaoAtual({
|
setVersaoAtual({
|
||||||
@@ -92,12 +92,49 @@ export const useItensPrioridadeFabricacaoFiltrado = () => {
|
|||||||
setFaseSelecionada(novaFase);
|
setFaseSelecionada(novaFase);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const incrementarRevisao = async () => {
|
||||||
|
if (!ofSelecionada || !faseSelecionada) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const novaRevisao = (versaoAtual?.revisao || 0) + 1;
|
||||||
|
const dataModificacao = new Date().toISOString();
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('prioridades_fabricacao')
|
||||||
|
.update({
|
||||||
|
revisao: novaRevisao,
|
||||||
|
data_ultima_modificacao: dataModificacao
|
||||||
|
})
|
||||||
|
.eq('of_number', ofSelecionada)
|
||||||
|
.eq('etapa_fase', faseSelecionada);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Erro ao incrementar revisão no DB:', error);
|
||||||
|
toast.error('Erro ao registrar nova revisão.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVersaoAtual(prev => ({
|
||||||
|
...prev!,
|
||||||
|
revisao: novaRevisao,
|
||||||
|
dataModificacao: dataModificacao
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast.success(`Revisão atualizada para ${novaRevisao}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao incrementar revisão:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...hookOriginal,
|
...hookOriginal,
|
||||||
itensPorPrioridade: itensFiltrados,
|
itensPorPrioridade: itensFiltrados,
|
||||||
ofSelecionada,
|
ofSelecionada,
|
||||||
faseSelecionada,
|
faseSelecionada,
|
||||||
versaoAtual,
|
versaoAtual,
|
||||||
onFiltroChange: handleFiltroChange
|
onFiltroChange: handleFiltroChange,
|
||||||
|
incrementarRevisao
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -363,8 +363,8 @@ export function usePecas() {
|
|||||||
marca: item.marca || '',
|
marca: item.marca || '',
|
||||||
descricao: item.descricao || '',
|
descricao: item.descricao || '',
|
||||||
quantidade: Number(item.quantidade) || 0,
|
quantidade: Number(item.quantidade) || 0,
|
||||||
peso_unitario: Math.round(Number(item.peso_unitario) || 0),
|
peso_unitario: Number(item.peso_unitario) || 0,
|
||||||
peso_total: Math.round(Number(item.peso_total) || 0),
|
peso_total: Number(item.peso_total) || 0,
|
||||||
tratamento_superficial: item.tratamento_superficial || '',
|
tratamento_superficial: item.tratamento_superficial || '',
|
||||||
material: item.material || '',
|
material: item.material || '',
|
||||||
perfil_principal: item.perfil_principal || '',
|
perfil_principal: item.perfil_principal || '',
|
||||||
|
|||||||
@@ -177,7 +177,10 @@ export const usePecasTable = (pecas: Peca[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return pecasParaCalcular.reduce((total, peca) => {
|
return pecasParaCalcular.reduce((total, peca) => {
|
||||||
return total + (peca.peso_total || 0);
|
const pTotal = (peca.peso_total && peca.peso_total > 0)
|
||||||
|
? peca.peso_total
|
||||||
|
: (peca.quantidade || 1) * (peca.peso_unitario || 0);
|
||||||
|
return total + pTotal;
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [filteredAndSortedPecas, selectedPecas]);
|
}, [filteredAndSortedPecas, selectedPecas]);
|
||||||
|
|
||||||
|
|||||||
@@ -44,51 +44,42 @@ export const useProcessChartData = (ofNumber: string) => {
|
|||||||
console.log(`Processo ${processName} encontrado com ID:`, processo.id);
|
console.log(`Processo ${processName} encontrado com ID:`, processo.id);
|
||||||
|
|
||||||
const hoje = new Date();
|
const hoje = new Date();
|
||||||
|
hoje.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
const seteDiasAtras = new Date(hoje);
|
const seteDiasAtras = new Date(hoje);
|
||||||
seteDiasAtras.setDate(hoje.getDate() - 7);
|
seteDiasAtras.setDate(hoje.getDate() - 7);
|
||||||
|
seteDiasAtras.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const quatorzeDiasAtras = new Date(hoje);
|
const quatorzeDiasAtras = new Date(hoje);
|
||||||
quatorzeDiasAtras.setDate(hoje.getDate() - 14);
|
quatorzeDiasAtras.setDate(hoje.getDate() - 14);
|
||||||
|
quatorzeDiasAtras.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const seisDiasAtras = new Date(hoje);
|
const seisDiasAtras = new Date(hoje);
|
||||||
seisDiasAtras.setDate(hoje.getDate() - 6);
|
seisDiasAtras.setDate(hoje.getDate() - 6);
|
||||||
|
seisDiasAtras.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
// Buscar apontamentos dos últimos 14 dias
|
// Buscar apontamentos completos para obter o total fabricado
|
||||||
const { data: apontamentos, error: apontamentosError } = await supabase
|
const { data: apontamentos, error: apontamentosError } = await supabase
|
||||||
.from('apontamentos_producao')
|
.from('apontamentos_producao')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
peca:pecas(peso_unitario),
|
peca:pecas!apontamentos_producao_peca_id_fkey(peso_unitario),
|
||||||
componente:componentes_peca(peso_unitario)
|
componente:componentes_peca!apontamentos_producao_componente_id_fkey(peso_unitario)
|
||||||
`)
|
`)
|
||||||
.eq('of_number', ofNumber)
|
.eq('of_number', ofNumber)
|
||||||
.eq('processo_id', processo.id)
|
.eq('processo_id', processo.id);
|
||||||
.gte('data_apontamento', quatorzeDiasAtras.toISOString().split('T')[0])
|
|
||||||
.lte('data_apontamento', hoje.toISOString().split('T')[0]);
|
|
||||||
|
|
||||||
if (apontamentosError) {
|
if (apontamentosError) {
|
||||||
console.error(`Erro ao buscar apontamentos para ${processName}:`, apontamentosError);
|
console.error(`Erro ao buscar apontamentos para ${processName}:`, apontamentosError);
|
||||||
return [
|
|
||||||
{ name: '7-15d', weight: 0 },
|
|
||||||
{ name: 'ult.7d', weight: 0 },
|
|
||||||
{ name: 'prev.7d', weight: 0 }
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Apontamentos encontrados para ${processName}:`, apontamentos?.length || 0);
|
console.log(`Apontamentos encontrados para ${processName}:`, apontamentos?.length || 0);
|
||||||
|
|
||||||
if (!apontamentos || apontamentos.length === 0) {
|
|
||||||
return [
|
|
||||||
{ name: '7-15d', weight: 0 },
|
|
||||||
{ name: 'ult.7d', weight: 0 },
|
|
||||||
{ name: 'prev.7d', weight: 0 }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcular pesos por período
|
|
||||||
let peso7a14Dias = 0;
|
let peso7a14Dias = 0;
|
||||||
let peso6DiasRecentes = 0;
|
let peso6DiasRecentes = 0;
|
||||||
|
let pesoTotalProcessado = 0;
|
||||||
|
|
||||||
|
if (apontamentos && apontamentos.length > 0) {
|
||||||
apontamentos.forEach(apontamento => {
|
apontamentos.forEach(apontamento => {
|
||||||
let pesoUnitario = 0;
|
let pesoUnitario = 0;
|
||||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
|
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
|
||||||
@@ -98,7 +89,15 @@ export const useProcessChartData = (ofNumber: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pesoTotal = pesoUnitario * apontamento.quantidade_produzida;
|
const pesoTotal = pesoUnitario * apontamento.quantidade_produzida;
|
||||||
const dataApontamento = new Date(apontamento.data_apontamento);
|
pesoTotalProcessado += pesoTotal;
|
||||||
|
|
||||||
|
if (apontamento.data_apontamento) {
|
||||||
|
// Se data_apontamento for apenas a data (YYYY-MM-DD), setamos pra meio dia pra evitar bugs de fuso
|
||||||
|
const dateStr = typeof apontamento.data_apontamento === 'string' && apontamento.data_apontamento.length <= 10
|
||||||
|
? apontamento.data_apontamento + 'T12:00:00'
|
||||||
|
: apontamento.data_apontamento;
|
||||||
|
|
||||||
|
const dataApontamento = new Date(dateStr);
|
||||||
|
|
||||||
// Período de 7-14 dias atrás
|
// Período de 7-14 dias atrás
|
||||||
if (dataApontamento >= quatorzeDiasAtras && dataApontamento < seteDiasAtras) {
|
if (dataApontamento >= quatorzeDiasAtras && dataApontamento < seteDiasAtras) {
|
||||||
@@ -108,7 +107,9 @@ export const useProcessChartData = (ofNumber: string) => {
|
|||||||
if (dataApontamento >= seisDiasAtras && dataApontamento <= hoje) {
|
if (dataApontamento >= seisDiasAtras && dataApontamento <= hoje) {
|
||||||
peso6DiasRecentes += pesoTotal;
|
peso6DiasRecentes += pesoTotal;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Calcular peso necessário para próximos 7 dias (meta)
|
// Calcular peso necessário para próximos 7 dias (meta)
|
||||||
const { data: ofData } = await supabase
|
const { data: ofData } = await supabase
|
||||||
@@ -117,12 +118,38 @@ export const useProcessChartData = (ofNumber: string) => {
|
|||||||
.eq('num_of', ofNumber)
|
.eq('num_of', ofNumber)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
let pesoTotalPlanejado = ofData?.peso_total || 0;
|
||||||
|
|
||||||
|
if (!pesoTotalPlanejado) {
|
||||||
|
const { data: fichaTecnica } = await supabase
|
||||||
|
.from('ficha_tecnica_contratos')
|
||||||
|
.select('quantidade')
|
||||||
|
.eq('of_number', ofNumber)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (fichaTecnica?.quantidade) {
|
||||||
|
pesoTotalPlanejado = fichaTecnica.quantidade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pesoTotalPlanejado) {
|
||||||
|
const { data: pecasData } = await supabase
|
||||||
|
.from('pecas')
|
||||||
|
.select('peso_unitario, quantidade')
|
||||||
|
.eq('of_number', ofNumber);
|
||||||
|
|
||||||
|
if (pecasData) {
|
||||||
|
pesoTotalPlanejado = pecasData.reduce((total, peca) => {
|
||||||
|
return total + ((peca.peso_unitario || 0) * (peca.quantidade || 0));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let pesoMeta = 0;
|
let pesoMeta = 0;
|
||||||
if (ofData?.peso_total && ofData?.data_prazo) {
|
if (pesoTotalPlanejado > 0) {
|
||||||
const dataFim = new Date(ofData.data_prazo);
|
const dataFim = ofData?.data_prazo ? new Date(ofData.data_prazo + 'T23:59:59') : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||||
const diasRestantes = Math.max(1, Math.ceil((dataFim.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24)));
|
const diasRestantes = Math.max(1, Math.ceil((dataFim.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24)));
|
||||||
const pesoTotalProcessado = peso7a14Dias + peso6DiasRecentes;
|
const pesoRestante = Math.max(0, pesoTotalPlanejado - pesoTotalProcessado);
|
||||||
const pesoRestante = Math.max(0, ofData.peso_total - pesoTotalProcessado);
|
|
||||||
|
|
||||||
// Meta para próximos 7 dias baseada no ritmo necessário
|
// Meta para próximos 7 dias baseada no ritmo necessário
|
||||||
if (diasRestantes <= 7) {
|
if (diasRestantes <= 7) {
|
||||||
@@ -133,7 +160,7 @@ export const useProcessChartData = (ofNumber: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resultado = [
|
const resultado = [
|
||||||
{ name: '7-15d', weight: Number(peso7a14Dias.toFixed(0)) }, // Mantido em Kg
|
{ name: '7-15d', weight: Number(peso7a14Dias.toFixed(0)) },
|
||||||
{ name: 'ult.7d', weight: Number(peso6DiasRecentes.toFixed(0)) },
|
{ name: 'ult.7d', weight: Number(peso6DiasRecentes.toFixed(0)) },
|
||||||
{ name: 'prev.7d', weight: Number(pesoMeta.toFixed(0)) }
|
{ name: 'prev.7d', weight: Number(pesoMeta.toFixed(0)) }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -45,20 +45,20 @@ export const useRelatorioDiario = (selectedDate: string) => {
|
|||||||
data_apontamento,
|
data_apontamento,
|
||||||
created_at,
|
created_at,
|
||||||
tipo_apontamento,
|
tipo_apontamento,
|
||||||
peca:pecas(
|
peca:pecas!apontamentos_producao_peca_id_fkey(
|
||||||
id,
|
id,
|
||||||
marca,
|
marca,
|
||||||
descricao,
|
descricao,
|
||||||
etapa_fase,
|
etapa_fase,
|
||||||
peso_unitario
|
peso_unitario
|
||||||
),
|
),
|
||||||
componente:componentes_peca(
|
componente:componentes_peca!apontamentos_producao_componente_id_fkey(
|
||||||
id,
|
id,
|
||||||
marca_componente,
|
marca_componente,
|
||||||
descricao,
|
descricao,
|
||||||
peso_unitario
|
peso_unitario
|
||||||
),
|
),
|
||||||
processo:processos_fabricacao(
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(
|
||||||
nome
|
nome
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
@@ -136,6 +136,7 @@ export const useRelatorioDiario = (selectedDate: string) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchApontamentos();
|
fetchApontamentos();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedDate]);
|
}, [selectedDate]);
|
||||||
|
|
||||||
// Calcular resumo por processo
|
// Calcular resumo por processo
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const useRelatorioPecasProcesso = (ofNumber: string) => {
|
|||||||
peca_id,
|
peca_id,
|
||||||
processo_id,
|
processo_id,
|
||||||
quantidade_produzida,
|
quantidade_produzida,
|
||||||
processos_fabricacao!inner(nome)
|
processos_fabricacao!apontamentos_producao_processo_id_fkey(nome)
|
||||||
`)
|
`)
|
||||||
.eq('of_number', ofNumber)
|
.eq('of_number', ofNumber)
|
||||||
.in('peca_id', pecaIds);
|
.in('peca_id', pecaIds);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface UserProfile {
|
|||||||
privileges?: {
|
privileges?: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
permissions: any;
|
permissions: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export interface UserPrivilege {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
permissions: any;
|
permissions: Record<string, unknown>;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,33 @@ export interface UserDependency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useUserManagement() {
|
export function useUserManagement() {
|
||||||
|
const deleteUserAndLogtoAccount = async (userId: string, email: string) => {
|
||||||
|
try {
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
|
|
||||||
|
// 1. Deleta do Logto primeiro
|
||||||
|
const response = await fetch(`${supabaseUrl}/functions/v1/delete-logto-user`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Failed to delete from Logto", await response.text());
|
||||||
|
toast.error("Erro ao excluir do Logto. A exclusão no sistema continuará.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Deleta do Supabase
|
||||||
|
return await deleteUser(userId, true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("Erro na comunicação com o servidor de autenticação");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [users, setUsers] = useState<UserProfile[]>([]);
|
const [users, setUsers] = useState<UserProfile[]>([]);
|
||||||
const [pendingUsers, setPendingUsers] = useState<UserProfile[]>([]);
|
const [pendingUsers, setPendingUsers] = useState<UserProfile[]>([]);
|
||||||
@@ -171,6 +198,7 @@ export function useUserManagement() {
|
|||||||
try {
|
try {
|
||||||
const { data: result, error } = await supabase.rpc('admin_create_user', {
|
const { data: result, error } = await supabase.rpc('admin_create_user', {
|
||||||
user_email: data.email,
|
user_email: data.email,
|
||||||
|
_caller_id: user?.id,
|
||||||
user_full_name: data.full_name || null,
|
user_full_name: data.full_name || null,
|
||||||
user_function_id: data.function_id || null,
|
user_function_id: data.function_id || null,
|
||||||
user_privilege_id: data.privilege_id || null
|
user_privilege_id: data.privilege_id || null
|
||||||
@@ -181,10 +209,11 @@ export function useUserManagement() {
|
|||||||
toast.success('Usuário criado com sucesso! Senha padrão: 1234');
|
toast.success('Usuário criado com sucesso! Senha padrão: 1234');
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error creating user:', error);
|
console.error('Error creating user:', error);
|
||||||
|
|
||||||
if (error.message?.includes('User with this email already exists')) {
|
const err = error as Error;
|
||||||
|
if (err.message?.includes('User with this email already exists')) {
|
||||||
toast.error('Este e-mail já está em uso');
|
toast.error('Este e-mail já está em uso');
|
||||||
} else if (error.message?.includes('Only admins can create new users')) {
|
} else if (error.message?.includes('Only admins can create new users')) {
|
||||||
toast.error('Apenas administradores podem criar usuários');
|
toast.error('Apenas administradores podem criar usuários');
|
||||||
@@ -230,6 +259,7 @@ export function useUserManagement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase.rpc('admin_delete_user', {
|
const { data, error } = await supabase.rpc('admin_delete_user', {
|
||||||
|
_caller_id: user?.id,
|
||||||
_user_id: userId
|
_user_id: userId
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,12 +269,13 @@ export function useUserManagement() {
|
|||||||
fetchUsers();
|
fetchUsers();
|
||||||
fetchPendingUsers();
|
fetchPendingUsers();
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Error deleting user:', error);
|
console.error('Error deleting user:', error);
|
||||||
|
|
||||||
if (error.message?.includes('Only admins can delete users')) {
|
const err = error as Error;
|
||||||
|
if (err.message?.includes('Only admins can delete users')) {
|
||||||
toast.error('Apenas administradores podem excluir usuários');
|
toast.error('Apenas administradores podem excluir usuários');
|
||||||
} else if (error.message?.includes('User cannot be deleted due to existing dependencies')) {
|
} else if (err.message?.includes('User cannot be deleted due to existing dependencies')) {
|
||||||
toast.error('Este usuário não pode ser excluído pois possui dados vinculados no sistema');
|
toast.error('Este usuário não pode ser excluído pois possui dados vinculados no sistema');
|
||||||
} else {
|
} else {
|
||||||
toast.error('Erro ao excluir usuário');
|
toast.error('Erro ao excluir usuário');
|
||||||
@@ -309,10 +340,9 @@ export function useUserManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle user status
|
|
||||||
const toggleUserStatus = async (userId: string, currentStatus: string) => {
|
const toggleUserStatus = async (userId: string, currentStatus: string) => {
|
||||||
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
|
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
|
||||||
await updateUser(userId, { status: newStatus as any });
|
await updateUser(userId, { status: newStatus as UserProfile['status'] });
|
||||||
};
|
};
|
||||||
|
|
||||||
// CRUD functions for Functions table
|
// CRUD functions for Functions table
|
||||||
@@ -367,7 +397,7 @@ export function useUserManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// CRUD functions for Privileges table - Updated to handle interface resources
|
// CRUD functions for Privileges table - Updated to handle interface resources
|
||||||
const createPrivilege = async (data: { name: string; description?: string; permissions?: any }, resourceKeys: string[] = []) => {
|
const createPrivilege = async (data: { name: string; description?: string; permissions?: Record<string, unknown> }, resourceKeys: string[] = []) => {
|
||||||
try {
|
try {
|
||||||
const { data: newPrivilege, error } = await supabase
|
const { data: newPrivilege, error } = await supabase
|
||||||
.from('privileges')
|
.from('privileges')
|
||||||
@@ -395,7 +425,7 @@ export function useUserManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePrivilege = async (id: string, data: { name: string; description?: string; permissions?: any }) => {
|
const updatePrivilege = async (id: string, data: { name: string; description?: string; permissions?: Record<string, unknown> }) => {
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('privileges')
|
.from('privileges')
|
||||||
@@ -458,6 +488,7 @@ export function useUserManagement() {
|
|||||||
updateUser,
|
updateUser,
|
||||||
toggleUserStatus,
|
toggleUserStatus,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
deleteUserAndLogtoAccount,
|
||||||
canDeleteUser,
|
canDeleteUser,
|
||||||
getUserDependencies,
|
getUserDependencies,
|
||||||
replaceUserWithDeleted,
|
replaceUserWithDeleted,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useUserRole } from '@/hooks/useUserRole';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type {
|
import type {
|
||||||
UserPermissions,
|
UserPermissions,
|
||||||
@@ -14,7 +13,6 @@ import type {
|
|||||||
|
|
||||||
export function useUserPermissions() {
|
export function useUserPermissions() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { isAdmin } = useUserRole();
|
|
||||||
const [userPermissions, setUserPermissions] = useState<UserPermissions>({
|
const [userPermissions, setUserPermissions] = useState<UserPermissions>({
|
||||||
can_admin: false,
|
can_admin: false,
|
||||||
can_create_update_delete: false,
|
can_create_update_delete: false,
|
||||||
@@ -88,10 +86,9 @@ export function useUserPermissions() {
|
|||||||
loadUserPermissions();
|
loadUserPermissions();
|
||||||
}, [loadUserPermissions]);
|
}, [loadUserPermissions]);
|
||||||
|
|
||||||
// Check if user has access to a resource
|
|
||||||
const hasAccess = useCallback((resourceKey?: string): boolean => {
|
const hasAccess = useCallback((resourceKey?: string): boolean => {
|
||||||
// Admin always has access
|
// Admin always has access
|
||||||
if (isAdmin) {
|
if (userPermissions.can_admin) {
|
||||||
console.log(`✅ Admin access granted for resource: ${resourceKey || 'general'}`);
|
console.log(`✅ Admin access granted for resource: ${resourceKey || 'general'}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -124,7 +121,7 @@ export function useUserPermissions() {
|
|||||||
const hasFunctionalAccess = Object.values(userPermissions).some(Boolean);
|
const hasFunctionalAccess = Object.values(userPermissions).some(Boolean);
|
||||||
console.log(`🔄 Fallback functional access for ${resourceKey}: ${hasFunctionalAccess}`, userPermissions);
|
console.log(`🔄 Fallback functional access for ${resourceKey}: ${hasFunctionalAccess}`, userPermissions);
|
||||||
return hasFunctionalAccess;
|
return hasFunctionalAccess;
|
||||||
}, [isAdmin, userPermissions, resourcePermissions]);
|
}, [userPermissions, resourcePermissions]);
|
||||||
|
|
||||||
// Check if user can access a specific route
|
// Check if user can access a specific route
|
||||||
const canAccessRoute = useCallback((route: string): boolean => {
|
const canAccessRoute = useCallback((route: string): boolean => {
|
||||||
@@ -148,10 +145,9 @@ export function useUserPermissions() {
|
|||||||
return hasAccess();
|
return hasAccess();
|
||||||
}, [hasAccess]);
|
}, [hasAccess]);
|
||||||
|
|
||||||
// Get permission level for a specific resource
|
|
||||||
const getResourcePermission = useCallback((resourceKey: ResourceKey): PermissionLevel => {
|
const getResourcePermission = useCallback((resourceKey: ResourceKey): PermissionLevel => {
|
||||||
// Admin always has full permissions
|
// Admin always has full permissions
|
||||||
if (isAdmin) return 'can_admin';
|
if (userPermissions.can_admin) return 'can_admin';
|
||||||
|
|
||||||
// Check specific resource permission
|
// Check specific resource permission
|
||||||
if (resourcePermissions[resourceKey]) {
|
if (resourcePermissions[resourceKey]) {
|
||||||
@@ -165,7 +161,7 @@ export function useUserPermissions() {
|
|||||||
if (userPermissions.can_view_only) return 'can_view_only';
|
if (userPermissions.can_view_only) return 'can_view_only';
|
||||||
|
|
||||||
return 'no_access';
|
return 'no_access';
|
||||||
}, [isAdmin, userPermissions, resourcePermissions]);
|
}, [userPermissions, resourcePermissions]);
|
||||||
|
|
||||||
// Check if user can perform a specific action on a resource
|
// Check if user can perform a specific action on a resource
|
||||||
const canPerformActionByResource = useCallback((
|
const canPerformActionByResource = useCallback((
|
||||||
@@ -190,9 +186,8 @@ export function useUserPermissions() {
|
|||||||
}
|
}
|
||||||
}, [getResourcePermission]);
|
}, [getResourcePermission]);
|
||||||
|
|
||||||
// Generic permission check for functional permissions
|
|
||||||
const canPerformAction = useCallback((action: ActionType): boolean => {
|
const canPerformAction = useCallback((action: ActionType): boolean => {
|
||||||
if (isAdmin) return true;
|
if (userPermissions.can_admin) return true;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
@@ -208,7 +203,7 @@ export function useUserPermissions() {
|
|||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [isAdmin, userPermissions]);
|
}, [userPermissions]);
|
||||||
|
|
||||||
// Set specific resource permission
|
// Set specific resource permission
|
||||||
const setResourcePermission = useCallback(async (
|
const setResourcePermission = useCallback(async (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface UserProfile {
|
|||||||
full_name: string | null;
|
full_name: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
profile_image_url: string | null;
|
profile_image_url: string | null;
|
||||||
|
status: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUserProfile() {
|
export function useUserProfile() {
|
||||||
@@ -21,6 +22,7 @@ export function useUserProfile() {
|
|||||||
setProfile(null);
|
setProfile(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const fetchUserProfile = async () => {
|
const fetchUserProfile = async () => {
|
||||||
@@ -30,7 +32,7 @@ export function useUserProfile() {
|
|||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('full_name, email, profile_image_url')
|
.select('full_name, email, profile_image_url, status')
|
||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -39,18 +41,20 @@ export function useUserProfile() {
|
|||||||
} else {
|
} else {
|
||||||
// Fallback para dados do usuário auth
|
// Fallback para dados do usuário auth
|
||||||
setProfile({
|
setProfile({
|
||||||
full_name: user.user_metadata?.full_name || null,
|
full_name: user.name || user.username || null,
|
||||||
email: user.email || null,
|
email: user.email || null,
|
||||||
profile_image_url: null
|
profile_image_url: null,
|
||||||
|
status: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao buscar perfil do usuário:', error);
|
console.error('Erro ao buscar perfil do usuário:', error);
|
||||||
// Fallback para dados do usuário auth
|
// Fallback para dados do usuário auth
|
||||||
setProfile({
|
setProfile({
|
||||||
full_name: user.user_metadata?.full_name || null,
|
full_name: user.name || user.username || null,
|
||||||
email: user.email || null,
|
email: user.email || null,
|
||||||
profile_image_url: null
|
profile_image_url: null,
|
||||||
|
status: null
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function useUserResourcePermissions(resourceKey: string) {
|
|||||||
const [users, setUsers] = useState<UserWithProfile[]>([]);
|
const [users, setUsers] = useState<UserWithProfile[]>([]);
|
||||||
const [resourcePermissions, setResourcePermissions] = useState<UserInterfacePermission[]>([]);
|
const [resourcePermissions, setResourcePermissions] = useState<UserInterfacePermission[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const channelRef = useRef<any>(null);
|
const channelRef = useRef<any>(null);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
@@ -66,19 +67,20 @@ export function useUserResourcePermissions(resourceKey: string) {
|
|||||||
// Enriquecer com dados do usuário
|
// Enriquecer com dados do usuário
|
||||||
const enrichedData = await Promise.all(
|
const enrichedData = await Promise.all(
|
||||||
(data || []).map(async (permission) => {
|
(data || []).map(async (permission) => {
|
||||||
const { data: profileData } = await supabase
|
const { data: profileDataArr } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('email, full_name')
|
.select('email, full_name')
|
||||||
.eq('id', permission.user_id)
|
.eq('id', permission.user_id)
|
||||||
.single();
|
.limit(1);
|
||||||
|
|
||||||
|
const profileData = profileDataArr && profileDataArr.length > 0 ? profileDataArr[0] : null;
|
||||||
return {
|
return {
|
||||||
user_id: permission.user_id,
|
user_id: permission.user_id,
|
||||||
resource_key: permission.resource_key,
|
resource_key: permission.resource_key,
|
||||||
permission_level: permission.permission as PermissionLevel,
|
permission_level: permission.permission as PermissionLevel,
|
||||||
created_at: permission.created_at,
|
created_at: permission.created_at,
|
||||||
updated_at: permission.updated_at,
|
updated_at: permission.updated_at,
|
||||||
profiles: profileData
|
profiles: profileData as any
|
||||||
} as UserInterfacePermission;
|
} as UserInterfacePermission;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -166,12 +168,13 @@ export function useUserResourcePermissions(resourceKey: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user exists
|
// Verify user exists
|
||||||
const { data: userExists, error: userError } = await supabase
|
const { data: userExistsArr, error: userError } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, email')
|
.select('id, email')
|
||||||
.eq('id', userId)
|
.eq('id', userId)
|
||||||
.single();
|
.limit(1);
|
||||||
|
|
||||||
|
const userExists = userExistsArr && userExistsArr.length > 0 ? userExistsArr[0] : null;
|
||||||
if (userError || !userExists) {
|
if (userError || !userExists) {
|
||||||
console.error('User not found:', { userId, userError });
|
console.error('User not found:', { userId, userError });
|
||||||
toast.error('Usuário não encontrado');
|
toast.error('Usuário não encontrado');
|
||||||
@@ -341,6 +344,7 @@ export function useUserResourcePermissions(resourceKey: string) {
|
|||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
cleanupChannel();
|
cleanupChannel();
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user?.id, isAdmin, resourceKey]);
|
}, [user?.id, isAdmin, resourceKey]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
|
|||||||
+23
-61
@@ -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 { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useUserPermissions } from '@/hooks/useUserPermissions';
|
||||||
import type { UserRole, AppRole } from '@/hooks/useUserPermissions/types';
|
import type { UserRole, AppRole } from '@/hooks/useUserPermissions/types';
|
||||||
|
|
||||||
export type AccessLevel = 'Total' | 'Parcial' | 'Restrita';
|
export type AccessLevel = 'Total' | 'Parcial' | 'Restrita';
|
||||||
|
|
||||||
export const useUserRole = () => {
|
export const useUserRole = () => {
|
||||||
const { user } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const { userPermissions, loading: permsLoading } = useUserPermissions();
|
||||||
|
|
||||||
const query = useQuery({
|
const loading = authLoading || permsLoading;
|
||||||
queryKey: ['user-role', user?.id],
|
|
||||||
queryFn: async () => {
|
// Sem usuário = sem permissão
|
||||||
if (!user?.id) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
accessLevel: 'Restrita' as AccessLevel,
|
accessLevel: 'Restrita' as AccessLevel,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isGerencia: false,
|
isGerencia: false,
|
||||||
isDiretoria: false,
|
isDiretoria: false,
|
||||||
role: 'user' as UserRole
|
role: 'user' as UserRole,
|
||||||
|
loading,
|
||||||
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Com usuário autenticado + validações do json de privilégios do DB
|
||||||
const { data: profile, error } = await supabase
|
const isAdminCheck = Boolean(userPermissions.can_admin);
|
||||||
.from('profiles')
|
|
||||||
.select('id, full_name')
|
|
||||||
.eq('id', user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error || !profile) {
|
|
||||||
return {
|
|
||||||
accessLevel: 'Restrita' as AccessLevel,
|
|
||||||
isAdmin: false,
|
|
||||||
isGerencia: false,
|
|
||||||
isDiretoria: false,
|
|
||||||
role: 'user' as UserRole
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now, return default permissions until we have proper role system
|
|
||||||
// This can be enhanced later with actual role checking
|
|
||||||
const accessLevel = 'Total' as AccessLevel;
|
|
||||||
const isAdmin = true; // Temporary - should be based on actual roles
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessLevel,
|
accessLevel: isAdminCheck ? 'Total' : 'Parcial' as AccessLevel,
|
||||||
isAdmin: true,
|
isAdmin: isAdminCheck,
|
||||||
isGerencia: false,
|
isGerencia: isAdminCheck, // fallback para componentes legados
|
||||||
isDiretoria: false,
|
isDiretoria: isAdminCheck, // fallback para componentes legados
|
||||||
role: 'admin' as UserRole
|
role: (isAdminCheck ? 'admin' : 'user') as UserRole,
|
||||||
};
|
loading,
|
||||||
} catch (error) {
|
error: null,
|
||||||
console.error('Erro ao buscar papel do usuário:', error);
|
|
||||||
return {
|
|
||||||
accessLevel: 'Restrita' as AccessLevel,
|
|
||||||
isAdmin: false,
|
|
||||||
isGerencia: false,
|
|
||||||
isDiretoria: false,
|
|
||||||
role: 'user' as UserRole
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled: !!user?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract the data and add loading/error handling
|
|
||||||
const { data, isLoading, error } = query;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
loading: isLoading,
|
|
||||||
error,
|
|
||||||
// Also include the query object for compatibility
|
|
||||||
...query
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 => {
|
export const hasRole = (userId: string | undefined, role: AppRole): boolean => {
|
||||||
// Implementation would check against supabase function
|
return false;
|
||||||
return false; // Placeholder
|
|
||||||
};
|
};
|
||||||
@@ -41,8 +41,8 @@ export const useValidacaoSequencialProcessos = () => {
|
|||||||
quantidade_produzida,
|
quantidade_produzida,
|
||||||
processo_id,
|
processo_id,
|
||||||
tipo_apontamento,
|
tipo_apontamento,
|
||||||
peca:pecas(marca),
|
peca:pecas!apontamentos_producao_peca_id_fkey(marca),
|
||||||
processo:processos_fabricacao(ordem)
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(ordem)
|
||||||
`)
|
`)
|
||||||
.eq('of_number', ofNumber)
|
.eq('of_number', ofNumber)
|
||||||
.eq('tipo_apontamento', 'peca');
|
.eq('tipo_apontamento', 'peca');
|
||||||
@@ -54,8 +54,8 @@ export const useValidacaoSequencialProcessos = () => {
|
|||||||
quantidade_produzida,
|
quantidade_produzida,
|
||||||
processo_id,
|
processo_id,
|
||||||
tipo_apontamento,
|
tipo_apontamento,
|
||||||
componente:componentes_peca(marca_componente),
|
componente:componentes_peca!apontamentos_producao_componente_id_fkey(marca_componente),
|
||||||
processo:processos_fabricacao(ordem)
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(ordem)
|
||||||
`)
|
`)
|
||||||
.eq('of_number', ofNumber)
|
.eq('of_number', ofNumber)
|
||||||
.eq('tipo_apontamento', 'componente');
|
.eq('tipo_apontamento', 'componente');
|
||||||
@@ -163,15 +163,33 @@ export const useValidacaoSequencialProcessos = () => {
|
|||||||
return { valido: true };
|
return { valido: true };
|
||||||
}, [historico, processos]);
|
}, [historico, processos]);
|
||||||
|
|
||||||
|
// Função auxiliar para calcular quantidade disponível do processo anterior
|
||||||
|
const calcularQuantidadeDisponivelProcessoAnterior = useCallback(async (
|
||||||
|
marcaItem: string,
|
||||||
|
ordemProcessoAnterior: number,
|
||||||
|
ofNumber: string,
|
||||||
|
tipoItem: 'peca' | 'componente'
|
||||||
|
): Promise<number> => {
|
||||||
|
const processoAnterior = processos.find(p => p.ordem === ordemProcessoAnterior);
|
||||||
|
if (!processoAnterior) return 0;
|
||||||
|
|
||||||
|
return await buscarQuantidadeProcessada(
|
||||||
|
marcaItem,
|
||||||
|
processoAnterior.id,
|
||||||
|
ofNumber,
|
||||||
|
tipoItem
|
||||||
|
);
|
||||||
|
}, [processos, buscarQuantidadeProcessada]);
|
||||||
|
|
||||||
// Calcular itens disponíveis para um processo específico
|
// Calcular itens disponíveis para um processo específico
|
||||||
const calcularItensDisponiveis = useCallback(async (
|
const calcularItensDisponiveis = useCallback(async (
|
||||||
ofNumber: string,
|
ofNumber: string,
|
||||||
processoId: string,
|
processoId: string,
|
||||||
pecas: any[],
|
pecas: Record<string, unknown>[],
|
||||||
componentes: any[]
|
componentes: Record<string, unknown>[]
|
||||||
): Promise<{
|
): Promise<{
|
||||||
pecasDisponiveis: any[];
|
pecasDisponiveis: Record<string, unknown>[];
|
||||||
componentesDisponiveis: any[];
|
componentesDisponiveis: Record<string, unknown>[];
|
||||||
}> => {
|
}> => {
|
||||||
const processoAtual = processos.find(p => p.id === processoId);
|
const processoAtual = processos.find(p => p.id === processoId);
|
||||||
if (!processoAtual) {
|
if (!processoAtual) {
|
||||||
@@ -179,28 +197,31 @@ export const useValidacaoSequencialProcessos = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ordemAtual = processoAtual.ordem;
|
const ordemAtual = processoAtual.ordem;
|
||||||
const pecasDisponiveis: any[] = [];
|
const pecasDisponiveis: Record<string, unknown>[] = [];
|
||||||
const componentesDisponiveis: any[] = [];
|
const componentesDisponiveis: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
// Processar peças
|
// Processar peças
|
||||||
for (const peca of pecas) {
|
for (const peca of pecas) {
|
||||||
|
const marcaPeca = String(peca.marca || '');
|
||||||
|
const qtdPeca = Number(peca.quantidade || 0);
|
||||||
|
|
||||||
if (ordemAtual === 1) {
|
if (ordemAtual === 1) {
|
||||||
// Primeiro processo: todas as peças estão disponíveis
|
// Primeiro processo: todas as peças estão disponíveis
|
||||||
pecasDisponiveis.push({
|
pecasDisponiveis.push({
|
||||||
...peca,
|
...peca,
|
||||||
quantidade_disponivel: peca.quantidade || 0
|
quantidade_disponivel: qtdPeca
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Calcular quantidade disponível baseada no processo anterior
|
// Calcular quantidade disponível baseada no processo anterior
|
||||||
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
|
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
|
||||||
peca.marca,
|
marcaPeca,
|
||||||
ordemAtual - 1,
|
ordemAtual - 1,
|
||||||
ofNumber,
|
ofNumber,
|
||||||
'peca'
|
'peca'
|
||||||
);
|
);
|
||||||
|
|
||||||
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
|
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
|
||||||
peca.marca,
|
marcaPeca,
|
||||||
processoId,
|
processoId,
|
||||||
ofNumber,
|
ofNumber,
|
||||||
'peca'
|
'peca'
|
||||||
@@ -219,23 +240,26 @@ export const useValidacaoSequencialProcessos = () => {
|
|||||||
|
|
||||||
// Processar componentes
|
// Processar componentes
|
||||||
for (const componente of componentes) {
|
for (const componente of componentes) {
|
||||||
|
const marcaComp = String(componente.marca_componente || '');
|
||||||
|
const qtdComp = Number(componente.quantidade_total || 0);
|
||||||
|
|
||||||
if (ordemAtual === 1) {
|
if (ordemAtual === 1) {
|
||||||
// Primeiro processo: todos os componentes estão disponíveis
|
// Primeiro processo: todos os componentes estão disponíveis
|
||||||
componentesDisponiveis.push({
|
componentesDisponiveis.push({
|
||||||
...componente,
|
...componente,
|
||||||
quantidade_disponivel: componente.quantidade_total || 0
|
quantidade_disponivel: qtdComp
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Calcular quantidade disponível baseada no processo anterior
|
// Calcular quantidade disponível baseada no processo anterior
|
||||||
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
|
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
|
||||||
componente.marca_componente,
|
marcaComp,
|
||||||
ordemAtual - 1,
|
ordemAtual - 1,
|
||||||
ofNumber,
|
ofNumber,
|
||||||
'componente'
|
'componente'
|
||||||
);
|
);
|
||||||
|
|
||||||
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
|
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
|
||||||
componente.marca_componente,
|
marcaComp,
|
||||||
processoId,
|
processoId,
|
||||||
ofNumber,
|
ofNumber,
|
||||||
'componente'
|
'componente'
|
||||||
@@ -253,25 +277,7 @@ export const useValidacaoSequencialProcessos = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { pecasDisponiveis, componentesDisponiveis };
|
return { pecasDisponiveis, componentesDisponiveis };
|
||||||
}, [processos, buscarQuantidadeProcessada]);
|
}, [processos, buscarQuantidadeProcessada, calcularQuantidadeDisponivelProcessoAnterior]);
|
||||||
|
|
||||||
// Função auxiliar para calcular quantidade disponível do processo anterior
|
|
||||||
const calcularQuantidadeDisponivelProcessoAnterior = async (
|
|
||||||
marcaItem: string,
|
|
||||||
ordemProcessoAnterior: number,
|
|
||||||
ofNumber: string,
|
|
||||||
tipoItem: 'peca' | 'componente'
|
|
||||||
): Promise<number> => {
|
|
||||||
const processoAnterior = processos.find(p => p.ordem === ordemProcessoAnterior);
|
|
||||||
if (!processoAnterior) return 0;
|
|
||||||
|
|
||||||
return await buscarQuantidadeProcessada(
|
|
||||||
marcaItem,
|
|
||||||
processoAnterior.id,
|
|
||||||
ofNumber,
|
|
||||||
tipoItem
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
historico,
|
historico,
|
||||||
|
|||||||
+107
-69
@@ -1,10 +1,12 @@
|
|||||||
// Client Logto puro (sem @logto/react, sem instalar pacotes)
|
// Client Logto puro (sem @logto/react, sem instalar pacotes)
|
||||||
// Usa fetch + localStorage direto
|
// Usa PKCE (Logto exige pra apps SPA)
|
||||||
|
|
||||||
const LOGTO_ENDPOINT = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
|
const LOGTO_ENDPOINT = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
|
||||||
const APP_ID = import.meta.env.VITE_LOGTO_APP_ID;
|
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 REDIRECT_URI =
|
||||||
const POST_LOGOUT_REDIRECT_URI = import.meta.env.VITE_LOGTO_POST_LOGOUT_REDIRECT_URI || window.location.origin;
|
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 TOKEN_KEY = 'logto_token';
|
||||||
const ID_TOKEN_KEY = 'logto_id_token';
|
const ID_TOKEN_KEY = 'logto_id_token';
|
||||||
@@ -13,6 +15,7 @@ const USER_KEY = 'logto_user';
|
|||||||
|
|
||||||
export interface LogtoUser {
|
export interface LogtoUser {
|
||||||
sub: string;
|
sub: string;
|
||||||
|
id?: string; // alias pra sub (compat com Supabase)
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -27,7 +30,7 @@ export interface LogtoTokens {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Storage helpers ===
|
// === Storage helpers ===
|
||||||
function saveTokens(t: LogtoTokens) {
|
function saveTokens(t: LogtoTokens): void {
|
||||||
localStorage.setItem(TOKEN_KEY, t.accessToken);
|
localStorage.setItem(TOKEN_KEY, t.accessToken);
|
||||||
localStorage.setItem(ID_TOKEN_KEY, t.idToken);
|
localStorage.setItem(ID_TOKEN_KEY, t.idToken);
|
||||||
if (t.refreshToken) localStorage.setItem(REFRESH_KEY, t.refreshToken);
|
if (t.refreshToken) localStorage.setItem(REFRESH_KEY, t.refreshToken);
|
||||||
@@ -43,7 +46,7 @@ function loadTokens(): LogtoTokens | null {
|
|||||||
return { accessToken, idToken, refreshToken, expiresAt };
|
return { accessToken, idToken, refreshToken, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTokens() {
|
function clearTokens(): void {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem(ID_TOKEN_KEY);
|
localStorage.removeItem(ID_TOKEN_KEY);
|
||||||
localStorage.removeItem(REFRESH_KEY);
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
@@ -51,12 +54,14 @@ function clearTokens() {
|
|||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === PKCE helpers (sem dependência) ===
|
// === PKCE helpers ===
|
||||||
function randomString(length: number): string {
|
function generateRandomString(len: number): string {
|
||||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||||
const arr = new Uint8Array(length);
|
const arr = new Uint8Array(len);
|
||||||
crypto.getRandomValues(arr);
|
crypto.getRandomValues(arr);
|
||||||
return Array.from(arr, (b) => charset[b % charset.length]).join('');
|
let out = '';
|
||||||
|
for (let i = 0; i < len; i++) out += charset[arr[i] % charset.length];
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sha256(input: string): Promise<ArrayBuffer> {
|
async function sha256(input: string): Promise<ArrayBuffer> {
|
||||||
@@ -64,73 +69,107 @@ async function sha256(input: string): Promise<ArrayBuffer> {
|
|||||||
return await crypto.subtle.digest('SHA-256', data);
|
return await crypto.subtle.digest('SHA-256', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64url(buf: ArrayBuffer): string {
|
function base64UrlEncode(buf: ArrayBuffer): string {
|
||||||
const bytes = new Uint8Array(buf);
|
const bytes = new Uint8Array(buf);
|
||||||
let str = '';
|
let str = '';
|
||||||
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
|
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
|
||||||
return btoa(str).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
const b64 = btoa(str);
|
||||||
|
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generatePkce(): Promise<{ verifier: string; challenge: string }> {
|
async function generatePkce(): Promise<{ verifier: string; challenge: string }> {
|
||||||
const verifier = randomString(64);
|
const verifier = generateRandomString(64);
|
||||||
const challenge = base64url(await sha256(verifier));
|
const challenge = base64UrlEncode(await sha256(verifier));
|
||||||
return { verifier, challenge };
|
return { verifier, challenge };
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Auth flow ===
|
// === Auth flow ===
|
||||||
export async function signIn(): Promise<void> {
|
export async function signIn(): Promise<void> {
|
||||||
const state = randomString(32);
|
// Limpa estado anterior pra evitar PKCE mismatch
|
||||||
const nonce = randomString(32);
|
try {
|
||||||
const { verifier, challenge } = await generatePkce();
|
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_state', state);
|
||||||
sessionStorage.setItem('logto_nonce', nonce);
|
sessionStorage.setItem('logto_nonce', nonce);
|
||||||
sessionStorage.setItem('logto_verifier', verifier);
|
sessionStorage.setItem('logto_verifier', pkce.verifier);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: APP_ID,
|
client_id: APP_ID,
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: REDIRECT_URI,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: 'openid profile email offline_access',
|
scope: 'openid profile email',
|
||||||
state,
|
state,
|
||||||
nonce,
|
nonce,
|
||||||
code_challenge: challenge,
|
code_challenge: pkce.challenge,
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
});
|
});
|
||||||
|
|
||||||
window.location.href = `${LOGTO_ENDPOINT}/oidc/auth?${params.toString()}`;
|
window.location.assign(LOGTO_ENDPOINT + '/oidc/auth?' + params.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let callbackPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
export async function handleCallback(): Promise<boolean> {
|
export async function handleCallback(): Promise<boolean> {
|
||||||
|
if (callbackPromise) return callbackPromise;
|
||||||
|
|
||||||
|
callbackPromise = (async () => {
|
||||||
|
try {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const code = url.searchParams.get('code');
|
const code = url.searchParams.get('code');
|
||||||
const state = url.searchParams.get('state');
|
const state = url.searchParams.get('state');
|
||||||
|
|
||||||
|
console.log('[Logto Callback] Recebido:', { hasCode: !!code, hasState: !!state });
|
||||||
|
|
||||||
if (!code) return false;
|
if (!code) return false;
|
||||||
|
|
||||||
const expectedState = sessionStorage.getItem('logto_state');
|
const expectedState = sessionStorage.getItem('logto_state');
|
||||||
const verifier = sessionStorage.getItem('logto_verifier');
|
const verifier = sessionStorage.getItem('logto_verifier') || '';
|
||||||
|
|
||||||
|
console.log('[Logto Callback] State match?', { received: state, expected: expectedState, hasVerifier: !!verifier });
|
||||||
|
|
||||||
if (state !== expectedState) {
|
if (state !== expectedState) {
|
||||||
console.error('Logto: state mismatch');
|
console.error('Logto: state mismatch');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const body = new URLSearchParams({
|
||||||
const res = await fetch(`${LOGTO_ENDPOINT}/oidc/token`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
client_id: APP_ID,
|
client_id: APP_ID,
|
||||||
code,
|
code,
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: REDIRECT_URI,
|
||||||
code_verifier: verifier || '',
|
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) {
|
if (!res.ok) {
|
||||||
console.error('Token exchange failed:', await res.text());
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,48 +185,51 @@ export async function handleCallback(): Promise<boolean> {
|
|||||||
const cleanUrl = window.location.origin + window.location.pathname;
|
const cleanUrl = window.location.origin + window.location.pathname;
|
||||||
window.history.replaceState({}, document.title, cleanUrl);
|
window.history.replaceState({}, document.title, cleanUrl);
|
||||||
|
|
||||||
sessionStorage.removeItem('logto_state');
|
|
||||||
sessionStorage.removeItem('logto_nonce');
|
|
||||||
sessionStorage.removeItem('logto_verifier');
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Callback error:', err);
|
console.error('Callback error:', err);
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
// Limpa a promise depois de um tempo pra permitir novos logins futuros,
|
||||||
|
// mas bloqueia execuções duplas imediatas (React Strict Mode / uso duplo)
|
||||||
|
setTimeout(() => {
|
||||||
|
callbackPromise = null;
|
||||||
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
return callbackPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUser(): Promise<LogtoUser | null> {
|
export async function getUser(): Promise<LogtoUser | null> {
|
||||||
const tokens = loadTokens();
|
const tokens = loadTokens();
|
||||||
if (!tokens) return null;
|
if (!tokens) return null;
|
||||||
|
|
||||||
// Cache do user info
|
|
||||||
const cached = localStorage.getItem(USER_KEY);
|
const cached = localStorage.getItem(USER_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached) as LogtoUser;
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${LOGTO_ENDPOINT}/oidc/me`, {
|
const res = await fetch(LOGTO_ENDPOINT + '/oidc/me', {
|
||||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
headers: { Authorization: 'Bearer ' + tokens.accessToken },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
// Token expirado - tentar refresh
|
|
||||||
const refreshed = await refreshAccessToken();
|
const refreshed = await refreshAccessToken();
|
||||||
if (refreshed) return getUser();
|
if (refreshed) return getUser();
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await res.json();
|
const user = (await res.json()) as LogtoUser;
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
const result: LogtoUser = { ...user, id: user.sub };
|
||||||
return user;
|
localStorage.setItem(USER_KEY, JSON.stringify(result));
|
||||||
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -198,14 +240,16 @@ async function refreshAccessToken(): Promise<boolean> {
|
|||||||
if (!tokens?.refreshToken) return false;
|
if (!tokens?.refreshToken) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${LOGTO_ENDPOINT}/oidc/token`, {
|
const body = new URLSearchParams({
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
client_id: APP_ID,
|
client_id: APP_ID,
|
||||||
refresh_token: tokens.refreshToken,
|
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;
|
if (!res.ok) return false;
|
||||||
@@ -225,32 +269,26 @@ async function refreshAccessToken(): Promise<boolean> {
|
|||||||
|
|
||||||
export async function signOut(): Promise<void> {
|
export async function signOut(): Promise<void> {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
localStorage.removeItem(USER_KEY);
|
|
||||||
window.location.href = `${LOGTO_ENDPOINT}/oidc/session/end?client_id=${APP_ID}&post_logout_redirect_uri=${encodeURIComponent(POST_LOGOUT_REDIRECT_URI)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestPasswordReset(email: string): Promise<{ ok: boolean; error?: string }> {
|
|
||||||
try {
|
try {
|
||||||
// Logto não tem endpoint público pra forgot-password
|
sessionStorage.removeItem('logto_state');
|
||||||
// Solução: usar o SDK account API quando user tá logado, OU enviar email via management API admin
|
sessionStorage.removeItem('logto_nonce');
|
||||||
// Aqui usamos a API direta do Logto (precisa de service token do app M2M)
|
sessionStorage.removeItem('logto_verifier');
|
||||||
|
} catch {
|
||||||
const res = await fetch(`${LOGTO_ENDPOINT}/api/forgot-password`, {
|
/* ignore */
|
||||||
method: 'POST',
|
}
|
||||||
headers: { 'Content-Type': 'application/json' },
|
window.location.assign(
|
||||||
body: JSON.stringify({ email }),
|
LOGTO_ENDPOINT +
|
||||||
});
|
'/oidc/session/end?client_id=' +
|
||||||
|
encodeURIComponent(APP_ID) +
|
||||||
if (!res.ok && res.status !== 404) {
|
'&post_logout_redirect_uri=' +
|
||||||
const err = await res.text();
|
encodeURIComponent(POST_LOGOUT_REDIRECT_URI)
|
||||||
return { ok: false, error: err };
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logto retorna 204 quando OK (mesmo se email não existe - segurança)
|
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 };
|
return { ok: true };
|
||||||
} catch (err: any) {
|
|
||||||
return { ok: false, error: err.message };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthenticated(): boolean {
|
export function isAuthenticated(): boolean {
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ const CadastroOF = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="quantidade" className="text-sm font-medium text-muted-foreground">Quantidade (t)</Label>
|
<Label htmlFor="quantidade" className="text-sm font-medium text-muted-foreground">Quantidade (kg)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="quantidade"
|
id="quantidade"
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ export default function CadastroPecasFiltrado() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<PecaForm
|
<PecaForm
|
||||||
ofNumbers={[ofSelecionada]} // Apenas a OF selecionada
|
ofNumbers={[ofSelecionada]} // Apenas a OF selecionada
|
||||||
|
ofDefault={ofSelecionada}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onImportCSV={handleImportCSV}
|
onImportCSV={handleImportCSV}
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -373,6 +358,12 @@ const ConversoresDados = () => {
|
|||||||
open={showGenericConverter}
|
open={showGenericConverter}
|
||||||
onOpenChange={setShowGenericConverter}
|
onOpenChange={setShowGenericConverter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modal de Conversão Advance Steel (PDF para Excel) */}
|
||||||
|
<AdvanceSteelConverter
|
||||||
|
open={showAdvanceSteelConverter}
|
||||||
|
onOpenChange={setShowAdvanceSteelConverter}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { CronogramaGantt } from '@/components/cronograma/CronogramaGantt';
|
|||||||
import { CronogramaPDF } from '@/components/cronograma/CronogramaPDF';
|
import { CronogramaPDF } from '@/components/cronograma/CronogramaPDF';
|
||||||
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
|
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
|
||||||
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
||||||
import { CronogramaOf } from '@/types/cronograma';
|
import { CronogramaOf, ProcessoCronograma } from '@/types/cronograma';
|
||||||
|
|
||||||
const CronogramaOF = () => {
|
const CronogramaOF = () => {
|
||||||
const { cronogramas, loading, loadCronogramas, deleteCronograma } = useCronogramas();
|
const { cronogramas, loading, loadCronogramas, deleteCronograma } = useCronogramas();
|
||||||
@@ -24,7 +24,7 @@ const CronogramaOF = () => {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedOF, setSelectedOF] = useState<string>('');
|
const [selectedOF, setSelectedOF] = useState<string>('');
|
||||||
const [showCronogramaForm, setShowCronogramaForm] = useState(false);
|
const [showCronogramaForm, setShowCronogramaForm] = useState(false);
|
||||||
const [selectedCronograma, setSelectedCronograma] = useState<any>(null);
|
const [selectedCronograma, setSelectedCronograma] = useState<CronogramaOf | null>(null);
|
||||||
const [showGanttChart, setShowGanttChart] = useState(false);
|
const [showGanttChart, setShowGanttChart] = useState(false);
|
||||||
const [showPDFGenerator, setShowPDFGenerator] = useState(false);
|
const [showPDFGenerator, setShowPDFGenerator] = useState(false);
|
||||||
const [cronogramaForGantt, setCronogramaForGantt] = useState<CronogramaOf | null>(null);
|
const [cronogramaForGantt, setCronogramaForGantt] = useState<CronogramaOf | null>(null);
|
||||||
@@ -42,7 +42,7 @@ const CronogramaOF = () => {
|
|||||||
return ofA.localeCompare(ofB);
|
return ofA.localeCompare(ofB);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleEdit = (cronograma: any) => {
|
const handleEdit = (cronograma: CronogramaOf) => {
|
||||||
if (!canEdit()) return;
|
if (!canEdit()) return;
|
||||||
setSelectedCronograma(cronograma);
|
setSelectedCronograma(cronograma);
|
||||||
setShowCronogramaForm(true);
|
setShowCronogramaForm(true);
|
||||||
@@ -68,6 +68,11 @@ const CronogramaOF = () => {
|
|||||||
setSelectedCronograma(null);
|
setSelectedCronograma(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveSuccess = () => {
|
||||||
|
loadCronogramas();
|
||||||
|
setActiveTab('cronogramas');
|
||||||
|
};
|
||||||
|
|
||||||
const handleCloseGanttChart = () => {
|
const handleCloseGanttChart = () => {
|
||||||
setShowGanttChart(false);
|
setShowGanttChart(false);
|
||||||
setCronogramaForGantt(null);
|
setCronogramaForGantt(null);
|
||||||
@@ -78,7 +83,7 @@ const CronogramaOF = () => {
|
|||||||
setCronogramaForPDF(null);
|
setCronogramaForPDF(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MobileCronogramaCard = ({ cronograma }: { cronograma: any }) => (
|
const MobileCronogramaCard = ({ cronograma }: { cronograma: CronogramaOf }) => (
|
||||||
<Card className="w-full mb-4 bg-card border-border">
|
<Card className="w-full mb-4 bg-card border-border">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
@@ -120,7 +125,7 @@ const CronogramaOF = () => {
|
|||||||
<div className="mt-1 text-card-foreground">
|
<div className="mt-1 text-card-foreground">
|
||||||
{Array.isArray(cronograma.processos) && cronograma.processos.length > 0 ? (
|
{Array.isArray(cronograma.processos) && cronograma.processos.length > 0 ? (
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
{cronograma.processos.map((processo: any, index: number) => (
|
{cronograma.processos.map((processo: ProcessoCronograma, index: number) => (
|
||||||
<div key={index} className="py-1 border-b border-border last:border-0">
|
<div key={index} className="py-1 border-b border-border last:border-0">
|
||||||
{processo.nome_processo || `Processo ${index + 1}`}
|
{processo.nome_processo || `Processo ${index + 1}`}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,6 +290,7 @@ const CronogramaOF = () => {
|
|||||||
cronograma={selectedCronograma}
|
cronograma={selectedCronograma}
|
||||||
onClose={handleCloseCronogramaForm}
|
onClose={handleCloseCronogramaForm}
|
||||||
isOpen={showCronogramaForm}
|
isOpen={showCronogramaForm}
|
||||||
|
onSaveSuccess={handleSaveSuccess}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+112
-235
@@ -24,7 +24,8 @@ const PrioridadesFabricacao = () => {
|
|||||||
ofSelecionada,
|
ofSelecionada,
|
||||||
faseSelecionada,
|
faseSelecionada,
|
||||||
versaoAtual,
|
versaoAtual,
|
||||||
onFiltroChange
|
onFiltroChange,
|
||||||
|
incrementarRevisao
|
||||||
} = useItensPrioridadeFabricacaoFiltrado();
|
} = useItensPrioridadeFabricacaoFiltrado();
|
||||||
|
|
||||||
const [showPecaSelector, setShowPecaSelector] = useState(false);
|
const [showPecaSelector, setShowPecaSelector] = useState(false);
|
||||||
@@ -58,17 +59,30 @@ const PrioridadesFabricacao = () => {
|
|||||||
const numBigBoxes = Math.floor(quantity / 5);
|
const numBigBoxes = Math.floor(quantity / 5);
|
||||||
const numSmallBoxes = quantity % 5;
|
const numSmallBoxes = quantity % 5;
|
||||||
for (let i = 0; i < numBigBoxes; i++) {
|
for (let i = 0; i < numBigBoxes; i++) {
|
||||||
boxesHtml += `<div class="tick-box-large"><span>5</span></div>`;
|
boxesHtml += `
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -1px; margin-right: 2px;">
|
||||||
|
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#f3f4f6" stroke="#4b5563" stroke-width="1" />
|
||||||
|
<text x="6.5" y="9.5" text-anchor="middle" font-size="8.5" font-family="Arial, sans-serif" font-weight="bold" fill="#4b5563">5</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
for (let i = 0; i < numSmallBoxes; i++) {
|
for (let i = 0; i < numSmallBoxes; i++) {
|
||||||
boxesHtml += `<div class="tick-box"></div>`;
|
boxesHtml += `
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -1px; margin-right: 2px;">
|
||||||
|
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < quantity; i++) {
|
for (let i = 0; i < quantity; i++) {
|
||||||
boxesHtml += `<div class="tick-box"></div>`;
|
boxesHtml += `
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -1px; margin-right: 2px;">
|
||||||
|
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `<div class="flex items-center flex-wrap gap-1">${boxesHtml}</div>`;
|
return `<span style="display: inline-block; vertical-align: middle; margin-left: 4px;">${boxesHtml}</span>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImprimirRelatorio = async () => {
|
const handleImprimirRelatorio = async () => {
|
||||||
@@ -77,6 +91,14 @@ const PrioridadesFabricacao = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let revisaoParaImprimir = versaoAtual?.revisao || 0;
|
||||||
|
if (window.confirm(`Deseja gerar uma nova revisão (Rev. ${(versaoAtual?.revisao || 0) + 1}) para esta impressão?`)) {
|
||||||
|
const sucesso = await incrementarRevisao();
|
||||||
|
if (sucesso) {
|
||||||
|
revisaoParaImprimir += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Criar nova janela para impressão
|
// Criar nova janela para impressão
|
||||||
const printWindow = window.open('', '_blank');
|
const printWindow = window.open('', '_blank');
|
||||||
@@ -106,11 +128,11 @@ const PrioridadesFabricacao = () => {
|
|||||||
|
|
||||||
const getCoresPrioridade = (codigo: string) => {
|
const getCoresPrioridade = (codigo: string) => {
|
||||||
switch (codigo) {
|
switch (codigo) {
|
||||||
case 'P1': return 'text-red-700 bg-red-100';
|
case 'P1': return 'text-red-700 bg-red-100 border-red-300';
|
||||||
case 'P2': return 'text-orange-700 bg-orange-100';
|
case 'P2': return 'text-orange-700 bg-orange-100 border-orange-300';
|
||||||
case 'P3': return 'text-blue-700 bg-blue-100';
|
case 'P3': return 'text-blue-700 bg-blue-100 border-blue-300';
|
||||||
case 'P4': return 'text-gray-700 bg-gray-200';
|
case 'P4': return 'text-gray-700 bg-gray-200 border-gray-300';
|
||||||
default: return 'text-gray-700 bg-gray-200';
|
default: return 'text-gray-700 bg-gray-200 border-gray-300';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,15 +141,15 @@ const PrioridadesFabricacao = () => {
|
|||||||
|
|
||||||
itemsContent += `
|
itemsContent += `
|
||||||
<div class="priority-group ${pageBreakClass}">
|
<div class="priority-group ${pageBreakClass}">
|
||||||
<h2 class="text-lg font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-3">
|
<h2 class="text-base font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-2.5 border">
|
||||||
${getPrioridadeNome(codigo)}
|
${getPrioridadeNome(codigo)}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1.5">
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Gerar linhas de itens (3 por linha)
|
// Gerar linhas de itens (3 por linha)
|
||||||
for (let i = 0; i < Math.ceil(itens.length / 3); i++) {
|
for (let i = 0; i < Math.ceil(itens.length / 3); i++) {
|
||||||
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50' : 'bg-white';
|
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50/70' : 'bg-white';
|
||||||
const rowItems = itens.slice(i * 3, (i + 1) * 3);
|
const rowItems = itens.slice(i * 3, (i + 1) * 3);
|
||||||
|
|
||||||
itemsContent += `<div class="grid grid-cols-3 gap-2 p-1 rounded-md ${bgColorClass}">`;
|
itemsContent += `<div class="grid grid-cols-3 gap-2 p-1 rounded-md ${bgColorClass}">`;
|
||||||
@@ -141,13 +163,13 @@ const PrioridadesFabricacao = () => {
|
|||||||
|
|
||||||
itemsContent += `
|
itemsContent += `
|
||||||
<div class="item-card">
|
<div class="item-card">
|
||||||
<div class="flex items-center flex-wrap gap-2 mb-2">
|
<div style="margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||||
<span class="font-semibold text-sm whitespace-nowrap">${marca} (${quantidade})</span>
|
<span style="font-size: 13px; font-weight: 700; color: #111827; vertical-align: middle; margin-right: 4px;">${marca} (${quantidade})</span>
|
||||||
<span class="text-xs font-medium text-gray-500">${infoType}</span>
|
<span style="font-size: 11px; font-weight: 600; color: #6b7280; vertical-align: middle;">${infoType}</span>
|
||||||
${tickBoxes}
|
${tickBoxes}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-xs">
|
<div class="item-signature">
|
||||||
<div class="border-b border-gray-400 pb-1 h-5">Data/Operador:</div>
|
Data/Operador:
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -176,20 +198,33 @@ const PrioridadesFabricacao = () => {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Checklist de Produção por Prioridade</title>
|
<title>Checklist de Produção por Prioridade</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: exact;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
|
.item-card {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.item-signature {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #4b5563;
|
||||||
|
border-bottom: 1px solid #9ca3af;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
height: 18px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
@media print {
|
@media print {
|
||||||
body {
|
body {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
.check-box-print {
|
|
||||||
border: 1px solid #333 !important;
|
|
||||||
}
|
|
||||||
.page-break {
|
.page-break {
|
||||||
page-break-before: always;
|
page-break-before: always;
|
||||||
}
|
}
|
||||||
@@ -200,85 +235,80 @@ const PrioridadesFabricacao = () => {
|
|||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.item-card {
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.tick-box {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border: 1px solid #6b7280;
|
|
||||||
display: inline-block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tick-box-large {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 1px solid #6b7280;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tick-box-large span {
|
|
||||||
color: #d1d5db;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-white">
|
<body class="bg-white">
|
||||||
<div class="max-w-4xl mx-auto p-6 sm:p-8">
|
<div class="max-w-4xl mx-auto p-6 sm:p-8">
|
||||||
<!-- Cabeçalho do Relatório -->
|
<!-- Cabeçalho do Relatório -->
|
||||||
<div class="flex justify-between items-center border-b-2 border-gray-800 pb-4 mb-4">
|
<div class="flex justify-between items-center border-b-2 border-gray-800 pb-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Checklist de Produção</h1>
|
<h1 class="text-2xl font-bold text-gray-900 leading-tight">Checklist de Produção</h1>
|
||||||
<p class="text-gray-600">Formulário para apontamento da fabricação.</p>
|
<p class="text-xs text-gray-600">Formulário para apontamento da fabricação.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="font-semibold">Data de Emissão: <span class="font-normal">${dataAtual}</span>
|
<p class="font-semibold text-sm">Data de Emissão: <span class="font-normal">${dataAtual}</span>
|
||||||
${versaoAtual ? `<span class="ml-2 text-gray-500">Rev. ${versaoAtual.revisao}</span>` : ''}
|
<span class="ml-2 text-gray-500 font-medium">Rev. ${revisaoParaImprimir}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Informações da OF e Fase (Layout Melhorado) -->
|
<!-- Informações da OF e Fase -->
|
||||||
<div class="border border-gray-200 bg-white p-4 rounded-lg mb-2">
|
<div class="border border-gray-200 bg-white p-3.5 rounded-lg mb-3">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-3">
|
||||||
<!-- Coluna OF -->
|
<!-- Coluna OF -->
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
|
<p class="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
|
||||||
<p class="text-base font-bold text-gray-800">${ofSelecionada}</p>
|
<p class="text-base font-bold text-gray-800 leading-snug">${ofSelecionada}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Coluna Fase -->
|
<!-- Coluna Fase -->
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-medium text-gray-500">Fase</p>
|
<p class="text-xs font-medium text-gray-500">Fase</p>
|
||||||
<p class="text-base font-bold text-gray-800">${faseSelecionada}</p>
|
<p class="text-base font-bold text-gray-800 leading-snug">${faseSelecionada}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Coluna Processo -->
|
<!-- Coluna Processo -->
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<p class="text-xs font-medium text-gray-500">PROCESSO</p>
|
<p class="text-xs font-medium text-gray-500 mb-1">PROCESSO</p>
|
||||||
<div class="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1">
|
<div style="margin-top: 2px;">
|
||||||
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Corte</span></div>
|
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
|
||||||
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Solda</span></div>
|
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
|
||||||
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Pintura</span></div>
|
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
|
||||||
<div class="flex items-center gap-1"><div class="w-4 h-4 border-2 border-gray-500 check-box-print"></div><span class="text-sm font-semibold text-gray-700">Expedição</span></div>
|
</svg>
|
||||||
|
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Corte</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
|
||||||
|
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Solda</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
|
||||||
|
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Pintura</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: inline-block; vertical-align: middle; margin-right: 16px; white-space: nowrap;">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" style="display: inline-block; vertical-align: -2px; margin-right: 4px;">
|
||||||
|
<rect x="0.75" y="0.75" width="12.5" height="12.5" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span style="font-size: 13px; font-weight: 600; color: #374151; vertical-align: middle;">Expedição</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Legenda -->
|
<!-- Legenda -->
|
||||||
<div class="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3">
|
<div class="text-xs text-gray-600 mb-5 flex items-center flex-wrap">
|
||||||
<span class="font-semibold">Legenda:</span>
|
<span class="font-semibold" style="margin-right: 6px;">Legenda:</span>
|
||||||
<span>Marca (Qtd)</span>
|
<span style="margin-right: 6px;">Marca (Qtd)</span>
|
||||||
<span class="font-medium text-gray-500">(S/M)</span>
|
<span class="font-medium text-gray-500" style="margin-right: 4px;">(S/M)</span>
|
||||||
<span>= Sem Montagem,</span>
|
<span style="margin-right: 6px;">= Sem Montagem,</span>
|
||||||
<span class="font-medium text-gray-500">(C/M)</span>
|
<span class="font-medium text-gray-500" style="margin-right: 4px;">(C/M)</span>
|
||||||
<span>= Com Montagem. Os quadrados</span>
|
<span style="margin-right: 4px;">= Com Montagem. Os quadrados</span>
|
||||||
<div class="tick-box inline-block"></div>
|
<svg width="13" height="13" viewBox="0 0 13 13" style="display: inline-block; vertical-align: -2px; margin: 0 4px;">
|
||||||
|
<rect x="0.5" y="0.5" width="12" height="12" rx="1.5" fill="#ffffff" stroke="#4b5563" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
<span>indicam o controle de peças fabricadas.</span>
|
<span>indicam o controle de peças fabricadas.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -369,7 +399,12 @@ const PrioridadesFabricacao = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowPrioridadesPDF(true)}
|
onClick={async () => {
|
||||||
|
if (window.confirm(`Deseja gerar uma nova revisão (Rev. ${(versaoAtual?.revisao || 0) + 1}) para este PDF?`)) {
|
||||||
|
await incrementarRevisao();
|
||||||
|
}
|
||||||
|
setShowPrioridadesPDF(true);
|
||||||
|
}}
|
||||||
disabled={!ofSelecionada || !faseSelecionada}
|
disabled={!ofSelecionada || !faseSelecionada}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4 mr-2" />
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
@@ -420,168 +455,10 @@ const PrioridadesFabricacao = () => {
|
|||||||
isOpen={showPrioridadesPDF}
|
isOpen={showPrioridadesPDF}
|
||||||
onClose={() => setShowPrioridadesPDF(false)}
|
onClose={() => setShowPrioridadesPDF(false)}
|
||||||
itensPorPrioridade={itensPorPrioridade}
|
itensPorPrioridade={itensPorPrioridade}
|
||||||
|
ofSelecionada={ofSelecionada}
|
||||||
|
faseSelecionada={faseSelecionada}
|
||||||
versaoAtual={versaoAtual}
|
versaoAtual={versaoAtual}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Componente oculto para impressão */}
|
|
||||||
<div className="hidden">
|
|
||||||
<div id="prioridades-pdf-content">
|
|
||||||
{(ofSelecionada && faseSelecionada) && (
|
|
||||||
<div className="bg-white text-black max-w-4xl mx-auto p-6">
|
|
||||||
{/* Usar o mesmo template do PDF */}
|
|
||||||
<div className="flex justify-between items-center border-b-2 border-gray-800 pb-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Checklist de Produção</h1>
|
|
||||||
<p className="text-gray-600">Formulário para apontamento da fabricação.</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="font-semibold">
|
|
||||||
Data de Emissão: <span className="font-normal">{new Date().toLocaleDateString('pt-BR')}</span>
|
|
||||||
{versaoAtual && (
|
|
||||||
<span className="ml-2 text-gray-500">Rev. {versaoAtual.revisao}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-gray-200 bg-white p-4 rounded-lg mb-2">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-6 gap-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-gray-500">Ordem de Fabricação (OF)</p>
|
|
||||||
<p className="text-base font-bold text-gray-800">{ofSelecionada}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-gray-500">Fase</p>
|
|
||||||
<p className="text-base font-bold text-gray-800">{faseSelecionada}</p>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<p className="text-xs font-medium text-gray-500">PROCESSO</p>
|
|
||||||
<div className="flex items-center flex-wrap gap-x-4 gap-y-1 mt-1">
|
|
||||||
{['Corte', 'Solda', 'Pintura', 'Expedição'].map((processo) => (
|
|
||||||
<div key={processo} className="flex items-center gap-1">
|
|
||||||
<div className="w-4 h-4 border-2 border-gray-500"></div>
|
|
||||||
<span className="text-sm font-semibold text-gray-700">{processo}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-600 mb-6 flex items-center flex-wrap gap-x-3">
|
|
||||||
<span className="font-semibold">Legenda:</span>
|
|
||||||
<span>Marca (Qtd)</span>
|
|
||||||
<span className="font-medium text-gray-500">(S/M)</span>
|
|
||||||
<span>= Sem Montagem,</span>
|
|
||||||
<span className="font-medium text-gray-500">(C/M)</span>
|
|
||||||
<span>= Com Montagem. Os quadrados indicam o controle de peças fabricadas.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Renderizar os itens por prioridade */}
|
|
||||||
<div className="space-y-8">
|
|
||||||
{['P1', 'P2', 'P3', 'P4'].map((codigo, priorityIndex) => {
|
|
||||||
const itens = itensPorPrioridade[codigo] || [];
|
|
||||||
if (itens.length === 0) return null;
|
|
||||||
|
|
||||||
const getPrioridadeNome = (codigo: string) => {
|
|
||||||
switch (codigo) {
|
|
||||||
case 'P1': return 'Prioridade P1 - Urgente';
|
|
||||||
case 'P2': return 'Prioridade P2 - Alta';
|
|
||||||
case 'P3': return 'Prioridade P3 - Média';
|
|
||||||
case 'P4': return 'Prioridade P4 - Baixa';
|
|
||||||
default: return 'Desconhecida';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCoresPrioridade = (codigo: string) => {
|
|
||||||
switch (codigo) {
|
|
||||||
case 'P1': return 'text-red-700 bg-red-100';
|
|
||||||
case 'P2': return 'text-orange-700 bg-orange-100';
|
|
||||||
case 'P3': return 'text-blue-700 bg-blue-100';
|
|
||||||
case 'P4': return 'text-gray-700 bg-gray-200';
|
|
||||||
default: return 'text-gray-700 bg-gray-200';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={codigo}>
|
|
||||||
<h2 className={`text-lg font-semibold ${getCoresPrioridade(codigo)} px-3 py-1 rounded-md inline-block mb-3`}>
|
|
||||||
{getPrioridadeNome(codigo)}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{Array.from({ length: Math.ceil(itens.length / 3) }, (_, i) => {
|
|
||||||
const bgColorClass = i % 2 !== 0 ? 'bg-gray-50' : 'bg-white';
|
|
||||||
const rowItems = itens.slice(i * 3, (i + 1) * 3);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={i} className={`grid grid-cols-3 gap-2 p-1 rounded-md ${bgColorClass}`}>
|
|
||||||
{rowItems.map((item) => {
|
|
||||||
const quantidade = item.quantidade_priorizada;
|
|
||||||
const marca = item.peca?.marca || 'N/A';
|
|
||||||
const temComponentes = item.peca?.tem_componentes;
|
|
||||||
const infoType = temComponentes ? '(C/M)' : '(S/M)';
|
|
||||||
|
|
||||||
// Gerar checkboxes
|
|
||||||
const generateTickBoxes = (quantity: number) => {
|
|
||||||
const boxes = [];
|
|
||||||
|
|
||||||
if (quantity > 10) {
|
|
||||||
const numBigBoxes = Math.floor(quantity / 5);
|
|
||||||
const numSmallBoxes = quantity % 5;
|
|
||||||
|
|
||||||
for (let i = 0; i < numBigBoxes; i++) {
|
|
||||||
boxes.push(
|
|
||||||
<div key={`big-${i}`} className="tick-box-large">
|
|
||||||
<span>5</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < numSmallBoxes; i++) {
|
|
||||||
boxes.push(<div key={`small-${i}`} className="tick-box"></div>);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < quantity; i++) {
|
|
||||||
boxes.push(<div key={i} className="tick-box"></div>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="flex items-center flex-wrap gap-1">{boxes}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={item.id} className="item-card">
|
|
||||||
<div className="flex items-center flex-wrap gap-2 mb-2">
|
|
||||||
<span className="font-semibold text-sm whitespace-nowrap">
|
|
||||||
{marca} ({quantidade})
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium text-gray-500">{infoType}</span>
|
|
||||||
{generateTickBoxes(quantidade)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs">
|
|
||||||
<div className="border-b border-gray-400 pb-1 h-5">Data/Operador:</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Preencher células vazias se necessário */}
|
|
||||||
{Array.from({ length: 3 - rowItems.length }, (_, emptyIndex) => (
|
|
||||||
<div key={`empty-${emptyIndex}`}></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</StandardPageLayout>
|
</StandardPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
+84
-101
@@ -2,75 +2,79 @@ import html2canvas from 'html2canvas';
|
|||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
|
|
||||||
export const generateProfessionalPDF = async (elementId: string, filename: string) => {
|
export const generateProfessionalPDF = async (elementId: string, filename: string) => {
|
||||||
|
let tempContainer: HTMLElement | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const element = document.getElementById(elementId);
|
const element = document.getElementById(elementId);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
throw new Error('Elemento não encontrado para gerar PDF');
|
throw new Error(`Elemento #${elementId} não encontrado para gerar PDF`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aguardar um pouco para garantir que o elemento esteja completamente renderizado
|
// Criar um container temporário isolado fora de modais/dialogs para evitar bugs de tamanho 0x0
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
tempContainer = document.createElement('div');
|
||||||
|
tempContainer.style.position = 'fixed';
|
||||||
|
tempContainer.style.left = '0';
|
||||||
|
tempContainer.style.top = '0';
|
||||||
|
tempContainer.style.width = '850px';
|
||||||
|
tempContainer.style.backgroundColor = '#ffffff';
|
||||||
|
tempContainer.style.zIndex = '-9999';
|
||||||
|
tempContainer.style.opacity = '1';
|
||||||
|
tempContainer.style.pointerEvents = 'none';
|
||||||
|
tempContainer.style.margin = '0';
|
||||||
|
tempContainer.style.padding = '20px';
|
||||||
|
tempContainer.style.boxSizing = 'border-box';
|
||||||
|
tempContainer.style.overflow = 'visible';
|
||||||
|
|
||||||
// Garantir que o elemento esteja visível e com dimensões corretas
|
// Clonar o elemento para o container isolado
|
||||||
const originalDisplay = element.style.display;
|
const clonedElement = element.cloneNode(true) as HTMLElement;
|
||||||
const originalVisibility = element.style.visibility;
|
clonedElement.style.display = 'block';
|
||||||
const originalPosition = element.style.position;
|
clonedElement.style.visibility = 'visible';
|
||||||
|
clonedElement.style.position = 'static';
|
||||||
|
clonedElement.style.width = '100%';
|
||||||
|
clonedElement.style.maxWidth = '100%';
|
||||||
|
clonedElement.style.height = 'auto';
|
||||||
|
clonedElement.style.maxHeight = 'none';
|
||||||
|
clonedElement.style.overflow = 'visible';
|
||||||
|
clonedElement.style.transform = 'none';
|
||||||
|
clonedElement.style.backgroundColor = '#ffffff';
|
||||||
|
clonedElement.style.color = '#000000';
|
||||||
|
|
||||||
element.style.display = 'block';
|
tempContainer.appendChild(clonedElement);
|
||||||
element.style.visibility = 'visible';
|
document.body.appendChild(tempContainer);
|
||||||
element.style.position = 'relative';
|
|
||||||
|
|
||||||
// Forçar um reflow
|
// Aguardar renderização e computação de layout
|
||||||
element.offsetHeight;
|
|
||||||
|
|
||||||
// Aguardar mais um pouco após forçar o reflow
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
console.log('Gerando PDF para elemento:', elementId);
|
// Elemento a ser renderizado pelo html2canvas
|
||||||
console.log('Dimensões do elemento:', {
|
const targetElement = (clonedElement.offsetHeight > 0 && clonedElement.offsetWidth > 0)
|
||||||
width: element.offsetWidth,
|
? clonedElement
|
||||||
height: element.offsetHeight,
|
: element;
|
||||||
scrollWidth: element.scrollWidth,
|
|
||||||
scrollHeight: element.scrollHeight
|
|
||||||
});
|
|
||||||
|
|
||||||
// Se o elemento não tem dimensões, isso pode causar PDF em branco
|
|
||||||
if (element.offsetWidth === 0 || element.offsetHeight === 0) {
|
|
||||||
throw new Error('Elemento tem dimensões zero - não é possível gerar PDF');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configurações otimizadas para html2canvas
|
// Configurações otimizadas para html2canvas
|
||||||
const canvas = await html2canvas(element, {
|
const canvas = await html2canvas(targetElement, {
|
||||||
scale: 2,
|
scale: 2,
|
||||||
useCORS: true,
|
useCORS: true,
|
||||||
allowTaint: false,
|
allowTaint: true,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
width: element.scrollWidth,
|
logging: false,
|
||||||
height: element.scrollHeight,
|
windowWidth: 1024,
|
||||||
scrollX: 0,
|
onclone: (clonedDoc) => {
|
||||||
scrollY: 0,
|
const found = clonedDoc.getElementById(elementId) || clonedDoc.body;
|
||||||
windowWidth: Math.max(element.scrollWidth, 1200),
|
if (found) {
|
||||||
windowHeight: Math.max(element.scrollHeight, 800),
|
(found as HTMLElement).style.display = 'block';
|
||||||
foreignObjectRendering: false,
|
(found as HTMLElement).style.visibility = 'visible';
|
||||||
removeContainer: false,
|
}
|
||||||
imageTimeout: 10000,
|
}
|
||||||
logging: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Canvas criado com sucesso:', {
|
if (!canvas || canvas.width === 0 || canvas.height === 0) {
|
||||||
width: canvas.width,
|
throw new Error('Não foi possível capturar o layout visual para o PDF');
|
||||||
height: canvas.height
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verificar se o canvas foi criado corretamente
|
|
||||||
if (canvas.width === 0 || canvas.height === 0) {
|
|
||||||
throw new Error('Canvas criado com dimensões zero');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restaurar estilos originais
|
const imgData = canvas.toDataURL('image/png', 1.0);
|
||||||
element.style.display = originalDisplay;
|
if (!imgData || !imgData.startsWith('data:image/png;base64,')) {
|
||||||
element.style.visibility = originalVisibility;
|
throw new Error('Falha ao processar os dados da imagem para o PDF');
|
||||||
element.style.position = originalPosition;
|
}
|
||||||
|
|
||||||
// Criar PDF com configurações otimizadas
|
// Criar PDF com configurações otimizadas
|
||||||
const pdf = new jsPDF({
|
const pdf = new jsPDF({
|
||||||
@@ -85,10 +89,10 @@ export const generateProfessionalPDF = async (elementId: string, filename: strin
|
|||||||
const pageHeight = 297;
|
const pageHeight = 297;
|
||||||
|
|
||||||
// Margens adequadas
|
// Margens adequadas
|
||||||
const marginTop = 15;
|
const marginTop = 12;
|
||||||
const marginBottom = 15;
|
const marginBottom = 12;
|
||||||
const marginLeft = 15;
|
const marginLeft = 12;
|
||||||
const marginRight = 15;
|
const marginRight = 12;
|
||||||
|
|
||||||
// Área útil para conteúdo
|
// Área útil para conteúdo
|
||||||
const contentWidth = pageWidth - marginLeft - marginRight;
|
const contentWidth = pageWidth - marginLeft - marginRight;
|
||||||
@@ -98,103 +102,82 @@ export const generateProfessionalPDF = async (elementId: string, filename: strin
|
|||||||
const imgWidth = contentWidth;
|
const imgWidth = contentWidth;
|
||||||
const imgHeight = (canvas.height * contentWidth) / canvas.width;
|
const imgHeight = (canvas.height * contentWidth) / canvas.width;
|
||||||
|
|
||||||
// Converter canvas para imagem
|
|
||||||
const imgData = canvas.toDataURL('image/png', 1.0);
|
|
||||||
|
|
||||||
console.log('Adicionando imagem ao PDF:', {
|
|
||||||
imgWidth,
|
|
||||||
imgHeight,
|
|
||||||
contentHeight,
|
|
||||||
totalPages: Math.ceil(imgHeight / contentHeight)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verificar se os dados da imagem foram gerados
|
|
||||||
if (!imgData || imgData === 'data:,') {
|
|
||||||
throw new Error('Falha ao gerar dados da imagem do canvas');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sistema de paginação
|
// Sistema de paginação
|
||||||
let currentY = 0;
|
let currentY = 0;
|
||||||
let pageNumber = 1;
|
let pageNumber = 1;
|
||||||
const totalPages = Math.ceil(imgHeight / contentHeight);
|
const totalPages = Math.max(1, Math.ceil(imgHeight / contentHeight));
|
||||||
|
|
||||||
// Função para adicionar rodapé com numeração
|
// Função para adicionar rodapé com numeração
|
||||||
const addFooter = (pageNum: number, totalPages: number) => {
|
const addFooter = (pageNum: number, totalPagesCount: number) => {
|
||||||
pdf.setFontSize(8);
|
pdf.setFontSize(8);
|
||||||
pdf.setTextColor(100, 100, 100);
|
pdf.setTextColor(120, 120, 120);
|
||||||
const footerText = `Página ${pageNum} de ${totalPages}`;
|
const footerText = `Página ${pageNum} de ${totalPagesCount}`;
|
||||||
const textWidth = pdf.getTextWidth(footerText);
|
const textWidth = pdf.getTextWidth(footerText);
|
||||||
const footerX = (pageWidth - textWidth) / 2;
|
const footerX = (pageWidth - textWidth) / 2;
|
||||||
const footerY = pageHeight - 8;
|
const footerY = pageHeight - 6;
|
||||||
pdf.text(footerText, footerX, footerY);
|
pdf.text(footerText, footerX, footerY);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Primeira página
|
|
||||||
if (imgHeight <= contentHeight) {
|
if (imgHeight <= contentHeight) {
|
||||||
// Conteúdo cabe em uma página
|
// Conteúdo cabe em uma página
|
||||||
pdf.addImage(imgData, 'PNG', marginLeft, marginTop, imgWidth, imgHeight);
|
pdf.addImage(imgData, 'PNG', marginLeft, marginTop, imgWidth, imgHeight, undefined, 'FAST');
|
||||||
addFooter(1, 1);
|
addFooter(1, 1);
|
||||||
} else {
|
} else {
|
||||||
// Conteúdo precisa de múltiplas páginas
|
// Conteúdo precisa de múltiplas páginas com corte preciso
|
||||||
while (currentY < imgHeight) {
|
while (currentY < imgHeight) {
|
||||||
if (pageNumber > 1) {
|
if (pageNumber > 1) {
|
||||||
pdf.addPage();
|
pdf.addPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calcular a altura restante do conteúdo
|
|
||||||
const remainingHeight = imgHeight - currentY;
|
const remainingHeight = imgHeight - currentY;
|
||||||
const currentPageHeight = Math.min(contentHeight, remainingHeight);
|
const currentPageHeight = Math.min(contentHeight, remainingHeight);
|
||||||
|
|
||||||
// Criar um canvas temporário para a seção atual
|
|
||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d');
|
||||||
|
|
||||||
if (tempCtx) {
|
if (tempCtx && currentPageHeight > 0) {
|
||||||
|
const sliceHeight = Math.round((currentPageHeight * canvas.width) / imgWidth);
|
||||||
tempCanvas.width = canvas.width;
|
tempCanvas.width = canvas.width;
|
||||||
tempCanvas.height = (currentPageHeight * canvas.width) / imgWidth;
|
tempCanvas.height = sliceHeight;
|
||||||
|
|
||||||
|
tempCtx.fillStyle = '#ffffff';
|
||||||
|
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
|
||||||
|
|
||||||
// Desenhar a seção atual do canvas original
|
|
||||||
tempCtx.drawImage(
|
tempCtx.drawImage(
|
||||||
canvas,
|
canvas,
|
||||||
0,
|
0,
|
||||||
(currentY * canvas.width) / imgWidth,
|
Math.round((currentY * canvas.width) / imgWidth),
|
||||||
canvas.width,
|
canvas.width,
|
||||||
tempCanvas.height,
|
sliceHeight,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
canvas.width,
|
canvas.width,
|
||||||
tempCanvas.height
|
sliceHeight
|
||||||
);
|
);
|
||||||
|
|
||||||
// Converter para dados de imagem
|
|
||||||
const tempImgData = tempCanvas.toDataURL('image/png', 1.0);
|
const tempImgData = tempCanvas.toDataURL('image/png', 1.0);
|
||||||
|
if (tempImgData && tempImgData.startsWith('data:image/png;base64,')) {
|
||||||
// Adicionar a seção ao PDF
|
pdf.addImage(tempImgData, 'PNG', marginLeft, marginTop, imgWidth, currentPageHeight, undefined, 'FAST');
|
||||||
pdf.addImage(tempImgData, 'PNG', marginLeft, marginTop, imgWidth, currentPageHeight);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adicionar rodapé
|
|
||||||
addFooter(pageNumber, totalPages);
|
addFooter(pageNumber, totalPages);
|
||||||
|
|
||||||
// Preparar para próxima página
|
|
||||||
currentY += contentHeight;
|
currentY += contentHeight;
|
||||||
pageNumber++;
|
pageNumber++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forçar o download do PDF
|
// Salvar arquivo PDF
|
||||||
console.log('Iniciando download do PDF:', filename);
|
|
||||||
pdf.save(filename);
|
pdf.save(filename);
|
||||||
|
|
||||||
// Aguardar um momento para garantir que o download seja iniciado
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
console.log('PDF salvo com sucesso:', filename);
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Erro detalhado ao gerar PDF:', error);
|
console.error('Erro detalhado ao gerar PDF:', error);
|
||||||
throw new Error(`Erro ao gerar PDF: ${error.message}`);
|
throw new Error(`Erro ao gerar PDF: ${error.message || error}`);
|
||||||
|
} finally {
|
||||||
|
if (tempContainer && tempContainer.parentNode) {
|
||||||
|
tempContainer.parentNode.removeChild(tempContainer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
|
|
||||||
|
const LOGTO_ENDPOINT = "https://logto-bzlued1boxl3t8ewsyn99an9.187.77.227.172.sslip.io";
|
||||||
|
const APP_ID = "rv73s8it14dkk6pdxegpy";
|
||||||
|
const APP_SECRET = "mB108PqAEa2pgTqNrBBdnVer12Sgf0pT";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { email } = await req.json();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return new Response(JSON.stringify({ error: "Email is required" }), {
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Get Logto M2M Access Token
|
||||||
|
const tokenResponse = await fetch(`${LOGTO_ENDPOINT}/oidc/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization: `Basic ${btoa(`${APP_ID}:${APP_SECRET}`)}`,
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
resource: "https://default.logto.app/api",
|
||||||
|
scope: "all",
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const errorText = await tokenResponse.text();
|
||||||
|
throw new Error(`Failed to get M2M token: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { access_token } = await tokenResponse.json();
|
||||||
|
|
||||||
|
// 2. Find User by Email
|
||||||
|
const searchResponse = await fetch(`${LOGTO_ENDPOINT}/api/users?search=${encodeURIComponent(email)}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!searchResponse.ok) {
|
||||||
|
throw new Error(`Failed to search user: ${await searchResponse.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await searchResponse.json();
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
return new Response(JSON.stringify({ message: "User not found in Logto" }), {
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const logtoUserId = users[0].id;
|
||||||
|
|
||||||
|
// 3. Delete User in Logto
|
||||||
|
const deleteResponse = await fetch(`${LOGTO_ENDPOINT}/api/users/${logtoUserId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deleteResponse.ok) {
|
||||||
|
throw new Error(`Failed to delete user: ${await deleteResponse.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: "User deleted successfully in Logto" }), {
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting Logto user:", error);
|
||||||
|
return new Response(JSON.stringify({ error: error.message }), {
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- Migration para corrigir a trigger atualizar_datas_reais_processo e prevenir erros 409 (Conflict) em inserções concorrentes de apontamentos
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.atualizar_datas_reais_processo()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
total_produzido NUMERIC;
|
||||||
|
total_planejado NUMERIC;
|
||||||
|
BEGIN
|
||||||
|
-- Se peca_id for nulo (ex: apontamento de componente sem peca_id vinculada), ignorar processamento de datas por peça
|
||||||
|
IF NEW.peca_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Buscar quantidade total planejada da peça
|
||||||
|
SELECT COALESCE(quantidade, 0) INTO total_planejado
|
||||||
|
FROM public.pecas
|
||||||
|
WHERE id = NEW.peca_id;
|
||||||
|
|
||||||
|
-- Inserir novo registro ou atualizar existente usando ON CONFLICT para evitar erro 409 (Constraint UNIQUE peca_id + processo_id)
|
||||||
|
INSERT INTO public.processos_pecas_datas (
|
||||||
|
peca_id,
|
||||||
|
processo_id,
|
||||||
|
data_inicio_real,
|
||||||
|
quantidade_total_planejada,
|
||||||
|
quantidade_total_produzida
|
||||||
|
) VALUES (
|
||||||
|
NEW.peca_id,
|
||||||
|
NEW.processo_id,
|
||||||
|
NEW.data_apontamento,
|
||||||
|
total_planejado,
|
||||||
|
NEW.quantidade_produzida
|
||||||
|
)
|
||||||
|
ON CONFLICT (peca_id, processo_id) DO UPDATE
|
||||||
|
SET
|
||||||
|
quantidade_total_produzida = (
|
||||||
|
SELECT COALESCE(SUM(quantidade_produzida), 0)
|
||||||
|
FROM public.apontamentos_producao
|
||||||
|
WHERE peca_id = NEW.peca_id AND processo_id = NEW.processo_id
|
||||||
|
),
|
||||||
|
data_conclusao_real = CASE
|
||||||
|
WHEN (
|
||||||
|
SELECT COALESCE(SUM(quantidade_produzida), 0)
|
||||||
|
FROM public.apontamentos_producao
|
||||||
|
WHERE peca_id = NEW.peca_id AND processo_id = NEW.processo_id
|
||||||
|
) >= processos_pecas_datas.quantidade_total_planejada THEN NEW.data_apontamento
|
||||||
|
ELSE processos_pecas_datas.data_conclusao_real
|
||||||
|
END,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN (
|
||||||
|
SELECT conrelid::regclass AS table_name, conname AS constraint_name
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE confrelid = 'auth.users'::regclass
|
||||||
|
AND conrelid::regclass::text LIKE '"TS_ERP"%'
|
||||||
|
) LOOP
|
||||||
|
EXECUTE 'ALTER TABLE ' || r.table_name || ' DROP CONSTRAINT IF EXISTS ' || r.constraint_name || ' CASCADE;';
|
||||||
|
RAISE NOTICE 'Dropped constraint % on %', r.constraint_name, r.table_name;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Change pecas.peso_unitario to numeric(10,3)
|
||||||
|
ALTER TABLE "TS_ERP".pecas ALTER COLUMN peso_unitario TYPE numeric(10,3);
|
||||||
|
|
||||||
|
-- Fix the sync_of_data function to explicitly refer to TS_ERP.ordens_fabricacao
|
||||||
|
CREATE OR REPLACE FUNCTION "TS_ERP".sync_of_data()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
-- Atualizar dados na tabela ordens_fabricacao quando ficha_tecnica_contratos for alterada
|
||||||
|
IF TG_TABLE_NAME = 'ficha_tecnica_contratos' THEN
|
||||||
|
UPDATE "TS_ERP".ordens_fabricacao
|
||||||
|
SET
|
||||||
|
gestor = NEW.gestor,
|
||||||
|
data_termino_prev = NEW.data_termino_prev,
|
||||||
|
peso_total = NEW.quantidade,
|
||||||
|
descritivo = NEW.descricao_resumida
|
||||||
|
WHERE num_of = NEW.of_number;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$function$;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Remover o gatilho que incrementava automaticamente a revisão do quadro de prioridades
|
||||||
|
DROP TRIGGER IF EXISTS trigger_increment_prioridade_fabricacao_revision ON "TS_ERP".itens_prioridade_fabricacao;
|
||||||
|
DROP TRIGGER IF EXISTS trigger_increment_prioridade_fabricacao_revision ON public.itens_prioridade_fabricacao;
|
||||||
|
|
||||||
|
-- Remover a função associada
|
||||||
|
DROP FUNCTION IF EXISTS public.increment_prioridade_fabricacao_revision();
|
||||||
+16
@@ -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();
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const { createClient } = require('@supabase/supabase-js');
|
||||||
|
const fs = require('fs');
|
||||||
|
const envContent = fs.readFileSync('.env', 'utf8');
|
||||||
|
const supabaseUrl = envContent.match(/VITE_SUPABASE_URL=\"(.*?)\"/)[1];
|
||||||
|
const supabaseKey = envContent.match(/VITE_SUPABASE_PUBLISHABLE_KEY=\"(.*?)\"/)[1];
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const pecasParaInserir = [{
|
||||||
|
of_number: 'B133',
|
||||||
|
etapa_fase: '1',
|
||||||
|
marca: 'M2',
|
||||||
|
descricao: 'Desc M2',
|
||||||
|
quantidade: 1,
|
||||||
|
peso_unitario: 10.5,
|
||||||
|
peso_total: 10.5,
|
||||||
|
tratamento_superficial: 'pintura',
|
||||||
|
material: 'A36',
|
||||||
|
perfil_principal: 'C',
|
||||||
|
tem_componentes: false,
|
||||||
|
user_id: '00000000-0000-0000-0000-000000000000',
|
||||||
|
prioridade: 'P4'
|
||||||
|
}];
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('pecas')
|
||||||
|
.insert(pecasParaInserir)
|
||||||
|
.select('id, of_number, etapa_fase, marca');
|
||||||
|
|
||||||
|
console.log('Result:', JSON.stringify({ data, error }, null, 2));
|
||||||
|
}
|
||||||
|
run();
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const { createClient } = require('@supabase/supabase-js');
|
||||||
|
const fs = require('fs');
|
||||||
|
const envContent = fs.readFileSync('.env', 'utf8');
|
||||||
|
const supabaseUrl = envContent.match(/VITE_SUPABASE_URL=\"(.*?)\"/)[1];
|
||||||
|
const supabaseKey = envContent.match(/VITE_SUPABASE_PUBLISHABLE_KEY=\"(.*?)\"/)[1];
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const pecasParaInserir = [{
|
||||||
|
of_number: 'B133',
|
||||||
|
etapa_fase: '1',
|
||||||
|
marca: 'M1',
|
||||||
|
descricao: 'Desc M1',
|
||||||
|
quantidade: 1,
|
||||||
|
peso_unitario: 10,
|
||||||
|
peso_total: 10,
|
||||||
|
tratamento_superficial: 'pintura',
|
||||||
|
material: 'A36',
|
||||||
|
perfil_principal: 'C',
|
||||||
|
tem_componentes: false,
|
||||||
|
user_id: '00000000-0000-0000-0000-000000000000', // invalid uuid? maybe this fails?
|
||||||
|
prioridade: 'P4'
|
||||||
|
}];
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('pecas')
|
||||||
|
.insert(pecasParaInserir)
|
||||||
|
.select('id, of_number, etapa_fase, marca');
|
||||||
|
|
||||||
|
console.log('Result:', JSON.stringify({ data, error }, null, 2));
|
||||||
|
}
|
||||||
|
run();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const { createClient } = require('@supabase/supabase-js');
|
||||||
|
const fs = require('fs');
|
||||||
|
const envContent = fs.readFileSync('.env', 'utf8');
|
||||||
|
const supabaseUrl = envContent.match(/VITE_SUPABASE_URL=\"(.*?)\"/)[1];
|
||||||
|
const supabaseKey = envContent.match(/VITE_SUPABASE_PUBLISHABLE_KEY=\"(.*?)\"/)[1];
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey, { db: { schema: 'TS_ERP' } });
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const { data: existingFicha } = await supabase
|
||||||
|
.from('ficha_tecnica_contratos')
|
||||||
|
.select('id')
|
||||||
|
.eq('of_number', 'B133')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (existingFicha) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('ficha_tecnica_contratos')
|
||||||
|
.update({ quantidade: 2 })
|
||||||
|
.eq('id', existingFicha.id);
|
||||||
|
console.log('Update result:', { data, error });
|
||||||
|
} else {
|
||||||
|
console.log('No ficha found for B133');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# TRACKSTEEL APP: DEPLOY E SINCRONIZAÇÃO
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "\n${CYAN}🚀 Iniciando Ciclo Automático de Deploy TrackSteelAPP...${NC}"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}📝 Sincronizando código...${NC}"
|
||||||
|
git add .
|
||||||
|
|
||||||
|
if git diff-index --quiet HEAD --; then
|
||||||
|
echo -e "${GREEN}✨ Nenhuma nova alteração para comitar.${NC}"
|
||||||
|
else
|
||||||
|
TIMESTAMP=$(date +"%d/%m/%Y %H:%M:%S")
|
||||||
|
MSG="${1:-🚀 Auto-deploy: TrackSteelAPP atualizado em $TIMESTAMP}"
|
||||||
|
echo -e "${CYAN}📤 Criando novo commit: $MSG${NC}"
|
||||||
|
git commit -m "$MSG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}📤 Sincronizando com o repositório remoto (git push)...${NC}"
|
||||||
|
git push
|
||||||
|
|
||||||
|
echo -e "${YELLOW}🔄 Disparando Deploy via API Oficial no Coolify...${NC}"
|
||||||
|
COOLIFY_TOKEN="19|ZJKUbBgyeNGcKrVkMI5gJ1uMHDC39d9VIShHL473a60d9882"
|
||||||
|
TRACKSTEEL_UUID="i8o44gggg00o88ccc8oo48kk"
|
||||||
|
curl -s -X GET "https://painel.reifonas.cloud/api/v1/deploy?uuid=${TRACKSTEEL_UUID}&force=false" \
|
||||||
|
-H "Authorization: Bearer $COOLIFY_TOKEN"
|
||||||
|
|
||||||
|
echo -e "\n${GREEN}🚀 Deploy oficial via API engatilhado com sucesso!${NC}"
|
||||||
|
echo -e "${GREEN}🏁 Ciclo concluído com sucesso.${NC}\n"
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Drop existing policies
|
||||||
|
DROP POLICY IF EXISTS "Users with permissions can view fichas" ON "TS_ERP".ficha_tecnica_contratos;
|
||||||
|
DROP POLICY IF EXISTS "Users with permissions can create fichas" ON "TS_ERP".ficha_tecnica_contratos;
|
||||||
|
DROP POLICY IF EXISTS "Users with permissions can update fichas" ON "TS_ERP".ficha_tecnica_contratos;
|
||||||
|
DROP POLICY IF EXISTS "Users with permissions can delete fichas" ON "TS_ERP".ficha_tecnica_contratos;
|
||||||
|
|
||||||
|
-- Function for view access (can_view_only or better)
|
||||||
|
CREATE OR REPLACE FUNCTION "TS_ERP".user_can_view_ofs(_user_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_is_admin boolean := false;
|
||||||
|
user_permissions record;
|
||||||
|
BEGIN
|
||||||
|
-- Admin bypass
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM "TS_ERP".user_roles
|
||||||
|
WHERE user_id = _user_id AND role = 'admin'
|
||||||
|
) INTO user_is_admin;
|
||||||
|
|
||||||
|
IF user_is_admin THEN RETURN true; END IF;
|
||||||
|
|
||||||
|
SELECT p.permissions INTO user_permissions
|
||||||
|
FROM "TS_ERP".profiles pr
|
||||||
|
JOIN "TS_ERP".privileges p ON pr.privilege_id = p.id
|
||||||
|
WHERE pr.id = _user_id;
|
||||||
|
|
||||||
|
IF user_permissions.permissions IS NOT NULL THEN
|
||||||
|
-- If they have ANY of these, they can view things
|
||||||
|
IF (user_permissions.permissions->>'can_admin')::boolean = true OR
|
||||||
|
(user_permissions.permissions->>'can_create_update_delete')::boolean = true OR
|
||||||
|
(user_permissions.permissions->>'can_create_only')::boolean = true OR
|
||||||
|
(user_permissions.permissions->>'can_view_only')::boolean = true THEN
|
||||||
|
RETURN true;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN false;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Function for write access (can_create_only or better)
|
||||||
|
CREATE OR REPLACE FUNCTION "TS_ERP".user_can_write_ofs(_user_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_is_admin boolean := false;
|
||||||
|
user_permissions record;
|
||||||
|
BEGIN
|
||||||
|
-- Admin bypass
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM "TS_ERP".user_roles
|
||||||
|
WHERE user_id = _user_id AND role = 'admin'
|
||||||
|
) INTO user_is_admin;
|
||||||
|
|
||||||
|
IF user_is_admin THEN RETURN true; END IF;
|
||||||
|
|
||||||
|
SELECT p.permissions INTO user_permissions
|
||||||
|
FROM "TS_ERP".profiles pr
|
||||||
|
JOIN "TS_ERP".privileges p ON pr.privilege_id = p.id
|
||||||
|
WHERE pr.id = _user_id;
|
||||||
|
|
||||||
|
IF user_permissions.permissions IS NOT NULL THEN
|
||||||
|
-- They need these to write
|
||||||
|
IF (user_permissions.permissions->>'can_admin')::boolean = true OR
|
||||||
|
(user_permissions.permissions->>'can_create_update_delete')::boolean = true OR
|
||||||
|
(user_permissions.permissions->>'can_create_only')::boolean = true THEN
|
||||||
|
RETURN true;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN false;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Function for full edit/delete (can_create_update_delete or better)
|
||||||
|
CREATE OR REPLACE FUNCTION "TS_ERP".user_can_edit_delete_ofs(_user_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_is_admin boolean := false;
|
||||||
|
user_permissions record;
|
||||||
|
BEGIN
|
||||||
|
-- Admin bypass
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM "TS_ERP".user_roles
|
||||||
|
WHERE user_id = _user_id AND role = 'admin'
|
||||||
|
) INTO user_is_admin;
|
||||||
|
|
||||||
|
IF user_is_admin THEN RETURN true; END IF;
|
||||||
|
|
||||||
|
SELECT p.permissions INTO user_permissions
|
||||||
|
FROM "TS_ERP".profiles pr
|
||||||
|
JOIN "TS_ERP".privileges p ON pr.privilege_id = p.id
|
||||||
|
WHERE pr.id = _user_id;
|
||||||
|
|
||||||
|
IF user_permissions.permissions IS NOT NULL THEN
|
||||||
|
-- They need these to edit or delete
|
||||||
|
IF (user_permissions.permissions->>'can_admin')::boolean = true OR
|
||||||
|
(user_permissions.permissions->>'can_create_update_delete')::boolean = true THEN
|
||||||
|
RETURN true;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN false;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Create policies with new granular rules
|
||||||
|
CREATE POLICY "Users can view fichas if they have view privileges"
|
||||||
|
ON "TS_ERP".ficha_tecnica_contratos FOR SELECT
|
||||||
|
USING ("TS_ERP".user_can_view_ofs(auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "Users can create fichas if they have write privileges"
|
||||||
|
ON "TS_ERP".ficha_tecnica_contratos FOR INSERT
|
||||||
|
WITH CHECK ("TS_ERP".user_can_write_ofs(auth.uid()));
|
||||||
|
|
||||||
|
-- For updates, we let full editors update, or the creator themselves if they retain write permissions
|
||||||
|
CREATE POLICY "Users can update fichas"
|
||||||
|
ON "TS_ERP".ficha_tecnica_contratos FOR UPDATE
|
||||||
|
USING (
|
||||||
|
"TS_ERP".user_can_edit_delete_ofs(auth.uid()) OR
|
||||||
|
(auth.uid() = user_id AND "TS_ERP".user_can_write_ofs(auth.uid()))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can delete fichas"
|
||||||
|
ON "TS_ERP".ficha_tecnica_contratos FOR DELETE
|
||||||
|
USING (
|
||||||
|
"TS_ERP".user_can_edit_delete_ofs(auth.uid()) OR
|
||||||
|
(auth.uid() = user_id AND "TS_ERP".user_can_write_ofs(auth.uid()))
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in New Issue
Block a user