✅ Restauração do código oficial do GPI-JWT-V3
This commit is contained in:
270
src/client/components/reports/StockInventoryReport.tsx
Normal file
270
src/client/components/reports/StockInventoryReport.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user