Restauração do código oficial do GPI-JWT-V3

This commit is contained in:
2026-03-18 21:55:33 +00:00
commit 405d121b0e
208 changed files with 38123 additions and 0 deletions

View File

@@ -0,0 +1,270 @@
import React from 'react';
import { format } from 'date-fns';
import type { StockItem, StockMovement } from '../../services/stockService';
import '../../styles/reports.css';
interface StockInventoryReportProps {
items: StockItem[];
movements: Map<string, StockMovement[]>; // Key: stockItemId
logoUrl?: string;
reportType?: 'PAINT' | 'THINNER';
}
export const StockInventoryReport: React.FC<StockInventoryReportProps> = ({ items, movements, logoUrl, reportType = 'PAINT' }) => {
// Agrupamento por Produto + Cor
const groups = React.useMemo(() => {
const map = new Map<string, {
key: string;
productName: string;
manufacturer: string;
color: string;
minStock: number;
totalQty: number;
unit: string;
items: StockItem[];
isLowStock: boolean;
}>();
items.forEach(item => {
const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Desconhecido';
const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
// Se for diluente, agrupar apenas pelo ID do produto, ignorando cor.
// Se for tinta, agrupar por produto + cor.
const key = reportType === 'THINNER'
? `${item.dataSheetId._id || item.dataSheetId}`
: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`;
if (!map.has(key)) {
map.set(key, {
key,
productName,
manufacturer,
color: reportType === 'THINNER' ? '-' : (item.color || '-'),
minStock: item.minStock || 0,
totalQty: 0,
unit: item.unit,
items: [],
isLowStock: false
});
}
const group = map.get(key)!;
group.items.push(item);
group.totalQty += item.quantity;
if (item.minStock && item.minStock > group.minStock) {
group.minStock = item.minStock;
}
});
// Avaliar status de estoque baixo por grupo
for (const group of map.values()) {
if (group.minStock > 0 && group.totalQty < group.minStock) {
group.isLowStock = true;
}
}
return Array.from(map.values());
}, [items, reportType]);
// Cálculos globais
const totalItems = items.length;
const totalQuantity = items.reduce((acc, item) => acc + item.quantity, 0);
const expiredItems = items.filter(item =>
item.expirationDate && new Date(item.expirationDate) < new Date()
).length;
const lowStockGroupsCount = groups.filter(g => g.isLowStock).length;
const formatMovementType = (type: string) => {
switch (type) {
case 'ENTRY': return 'Entrada';
case 'ADJUSTMENT': return 'Ajuste';
case 'CONSUMPTION': return 'Consumo';
default: return type;
}
};
return (
<div className="report-container print:block hidden" id="stock-inventory-report">
<header className="report-header">
<div className="brand">
{logoUrl ? (
<img src={logoUrl} alt="Logo" className="brand-logo" />
) : (
<div className="logo-placeholder"></div>
)}
</div>
<div className="text-center">
<div className="brand-title">INVENTÁRIO DE ESTOQUE - {reportType === 'THINNER' ? 'DILUENTES' : 'TINTAS'}</div>
<div className="brand-subtitle">
Controle de {reportType === 'THINNER' ? 'Diluentes' : 'Tintas'} Agrupado por Produto
</div>
</div>
<div className="meta">
<div><strong>Data:</strong> {format(new Date(), 'dd/MM/yyyy')}</div>
<div><strong>Grupos Críticos:</strong> <span style={{ color: lowStockGroupsCount > 0 ? 'red' : 'inherit' }}>{lowStockGroupsCount}</span></div>
</div>
</header>
<section className="summary">
<div className="summary-item">
<div className="summary-label">Total de Lotes</div>
<div className="summary-value">{totalItems}</div>
<div className="summary-sub">Entradas ativas</div>
</div>
<div className="summary-item">
<div className="summary-label">Volume Total</div>
<div className="summary-value">{totalQuantity.toFixed(1)}</div>
<div className="summary-sub">Litros em estoque</div>
</div>
<div className="summary-item">
<div className="summary-label">Alertas de Estoque</div>
<div className="summary-value" style={{ color: lowStockGroupsCount > 0 ? '#ef4444' : '#10b981' }}>{lowStockGroupsCount}</div>
<div className="summary-sub">Produtos abaixo do mínimo</div>
</div>
<div className="summary-item">
<div className="summary-label">Vencidos</div>
<div className="summary-value" style={{ color: expiredItems > 0 ? '#ef4444' : 'inherit' }}>{expiredItems}</div>
<div className="summary-sub">Lotes expirados</div>
</div>
</section>
<div className="section-title">
<h2>DETALHAMENTO DO ESTOQUE</h2>
</div>
<table className="table">
<thead>
<tr>
<th className="col-obra" style={{ width: '35%' }}>Produto / RR</th>
<th className="col-cron" style={{ width: '20%' }}>Lote / Validade</th>
<th className="col-peso text-center" style={{ width: '15%' }}>Quantidade</th>
{reportType === 'PAINT' && <th className="col-tinta" style={{ width: '15%' }}>Cor</th>}
<th className="col-cor" style={{ width: '15%' }}>Nota Fiscal</th>
</tr>
</thead>
<tbody>
{groups.map((group) => (
<React.Fragment key={group.key}>
{/* Group Header Row */}
<tr style={{ backgroundColor: '#f3f4f6', borderTop: '2px solid #e5e7eb', borderBottom: '1px solid #e5e7eb' }}>
<td colSpan={2} style={{ padding: '8px 12px' }}>
<div style={{ fontWeight: 'bold', fontSize: '10pt', color: '#111827' }}>
{group.productName.toUpperCase()}
</div>
<div style={{ fontSize: '7pt', color: '#6b7280' }}>
{group.manufacturer}
</div>
</td>
<td className="text-center" style={{ padding: '8px 12px' }}>
<div style={{ fontWeight: 'bold', fontSize: '10pt', color: group.isLowStock ? '#ef4444' : '#111827' }}>
{group.isLowStock && <span style={{ marginRight: '4px' }}></span>}
{group.totalQty.toFixed(1)} {group.unit}
</div>
{group.minStock > 0 && (
<div style={{ fontSize: '7pt', color: '#6b7280' }}>
Mín: {group.minStock}
</div>
)}
</td>
{reportType === 'PAINT' && (
<td style={{ padding: '8px 12px', verticalAlign: 'middle', fontWeight: 'bold', fontSize: '9pt', color: '#374151' }}>
{group.color}
</td>
)}
<td style={{ padding: '8px 12px', textAlign: 'right', fontSize: '8pt', color: '#6b7280' }}>
{group.items.length} lote(s)
</td>
</tr>
{/* Individual Items */}
{group.items.map((item) => {
const isExpired = item.expirationDate && new Date(item.expirationDate) < new Date();
const itemMovements = movements.get(item._id!) || [];
return (
<React.Fragment key={item._id}>
<tr>
<td className="col-obra" style={{ paddingLeft: '24px' }}>
<div className="obra-nome" style={{ fontSize: '9pt' }}>RR: <strong>{item.rrNumber}</strong></div>
</td>
<td className="col-cron">
<div className="cron">
<strong>Lote:</strong> {item.batchNumber}<br />
{reportType === 'PAINT' && (
<>
<strong>Val:</strong>{' '}
<span style={{ color: isExpired ? '#ef4444' : 'inherit', fontWeight: isExpired ? 'bold' : 'normal' }}>
{item.expirationDate ? format(new Date(item.expirationDate), 'dd/MM/yyyy') : '-'}
</span>
</>
)}
</div>
</td>
<td className="col-peso text-center">
<div style={{ fontSize: '9pt' }}>
{item.quantity.toFixed(1)} {item.unit}
</div>
</td>
{reportType === 'PAINT' && (
<td className="col-tinta">
{/* Cor is already in group header, repeated here only if needed or keep empty/dash */}
<div style={{ opacity: 0.5 }}>-</div>
</td>
)}
<td className="col-cor">
<div style={{ fontSize: '8pt' }}>
{item.invoiceNumber || '-'}
</div>
{item.receivedBy && (
<div style={{ fontSize: '7pt', color: '#9ca3af' }}> Rec: {item.receivedBy}</div>
)}
</td>
</tr>
{/* Movimentações inline */}
{itemMovements.length > 0 && (
<tr>
<td colSpan={reportType === 'PAINT' ? 5 : 4} style={{ padding: '2px 24px 8px 24px', border: 'none' }}>
<div style={{ fontSize: '6.5pt', color: '#9ca3af', borderLeft: '2px solid #e5e7eb', paddingLeft: '8px' }}>
{itemMovements.slice(0, 5).map((mov, idx) => (
<span key={mov._id} style={{ marginRight: '8px', whiteSpace: 'nowrap' }}>
{format(new Date(mov.date), 'dd/MM/yy')} -{' '}
<strong>{formatMovementType(mov.type)}</strong>:{' '}
{mov.type === 'CONSUMPTION' ? '-' : '+'}{Math.abs(mov.quantity)}L
{mov.responsible && ` (${mov.responsible})`}
{idx < Math.min(itemMovements.length, 5) - 1 && ' | '}
</span>
))}
{itemMovements.length > 5 && (
<span style={{ fontStyle: 'italic', color: '#9ca3af' }}>
... +{itemMovements.length - 5} movimentações
</span>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</React.Fragment>
))}
</tbody>
</table>
<footer className="report-footer">
<div className="system-title">
SteelPaint - Gestão de Pintura Industrial
</div>
<div>
Gerado em {format(new Date(), 'dd/MM/yyyy')} às {format(new Date(), 'HH:mm')}h
</div>
<div className="sig-group">
<div className="sig-line">Responsável Estoque<span></span></div>
<div className="sig-line">Responsável Qualidade<span></span></div>
</div>
</footer>
</div>
);
};