Files
GPI/src/client/components/reports/StockInventoryReport.tsx

271 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};