chore: synchronize local fixes to gitea
This commit is contained in:
442
src/client/pages/StockDashboard.tsx
Normal file
442
src/client/pages/StockDashboard.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Plus, Search, ArrowDown, Edit, Trash2, History, Printer, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { stockService, type StockItem, type StockMovement } from '../services/stockService';
|
||||
import { StockModal } from '../components/modals/StockModal';
|
||||
import { StockOutModal } from '../components/modals/StockOutModal';
|
||||
import { StockHistoryModal } from '../components/modals/StockHistoryModal';
|
||||
import { StockInventoryReport } from '../components/reports/StockInventoryReport';
|
||||
import { DiluentListModal } from '../components/modals/DiluentListModal';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { useOrganization } from '@clerk/clerk-react';
|
||||
import { useSystemSettings } from '../context/SystemSettingsContext';
|
||||
|
||||
export const StockDashboard: React.FC = () => {
|
||||
// ... rest of component
|
||||
const { isAdmin } = useAuth();
|
||||
const { organization } = useOrganization();
|
||||
const { settings } = useSystemSettings();
|
||||
|
||||
const [items, setItems] = useState<StockItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showOutModal, setShowOutModal] = useState(false);
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<StockItem | null>(null);
|
||||
const [isPrintingInventory, setIsPrintingInventory] = useState(false);
|
||||
const [allMovements, setAllMovements] = useState<Map<string, StockMovement[]>>(new Map());
|
||||
const [activeTab, setActiveTab] = useState<'PAINT' | 'THINNER'>('PAINT');
|
||||
const [showDiluentModal, setShowDiluentModal] = useState(false);
|
||||
|
||||
const logoUrl = settings?.appLogoUrl || organization?.imageUrl;
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await stockService.getAll();
|
||||
setItems(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching stock:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, []);
|
||||
|
||||
const handleEdit = (item: StockItem) => {
|
||||
setSelectedItem(item);
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleOut = (item: StockItem) => {
|
||||
setSelectedItem(item);
|
||||
setShowOutModal(true);
|
||||
};
|
||||
|
||||
const handleHistory = (item: StockItem) => {
|
||||
setSelectedItem(item);
|
||||
setShowHistoryModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Tem certeza que deseja excluir este lote? Todo o histórico será perdido.')) {
|
||||
try {
|
||||
await stockService.delete(id);
|
||||
fetchItems();
|
||||
} catch (error) {
|
||||
console.error('Error deleting item:', error);
|
||||
alert('Erro ao excluir item.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrintInventory = async () => {
|
||||
try {
|
||||
setIsPrintingInventory(true);
|
||||
|
||||
// Buscar movimentações para todos os itens
|
||||
const movementsMap = new Map<string, StockMovement[]>();
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
try {
|
||||
const movements = await stockService.getMovements(item._id!);
|
||||
movementsMap.set(item._id!, movements);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching movements for ${item._id}:`, error);
|
||||
movementsMap.set(item._id!, []);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setAllMovements(movementsMap);
|
||||
|
||||
// Aguardar renderização e imprimir
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
setIsPrintingInventory(false);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error generating inventory report:', error);
|
||||
alert('Erro ao gerar relatório de inventário.');
|
||||
setIsPrintingInventory(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const filteredItems = items.filter(item => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
// Handle type checking carefully. If type is missing, assume PAINT.
|
||||
const type = (typeof item.dataSheetId === 'object' ? item.dataSheetId.type : '') || 'PAINT';
|
||||
const isThinner = type === 'THINNER' || type === 'DILUENTE';
|
||||
|
||||
// Tab Filter
|
||||
if (activeTab === 'THINNER' && !isThinner) return false;
|
||||
if (activeTab === 'PAINT' && isThinner) return false;
|
||||
|
||||
const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : '';
|
||||
const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
|
||||
|
||||
return (
|
||||
item.rrNumber.toLowerCase().includes(searchLower) ||
|
||||
item.batchNumber.toLowerCase().includes(searchLower) ||
|
||||
productName.toLowerCase().includes(searchLower) ||
|
||||
manufacturer.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const groupedItems = React.useMemo(() => {
|
||||
const groups = new Map<string, { items: StockItem[], totalQty: number, minStock: number, unit: string, productName: string, color: string, manufacturer: string }>();
|
||||
|
||||
filteredItems.forEach(item => {
|
||||
const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Unknown';
|
||||
const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
|
||||
const key = `${item.dataSheetId._id || item.dataSheetId}-${item.color}`;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
items: [],
|
||||
totalQty: 0,
|
||||
minStock: item.minStock || 0,
|
||||
unit: item.unit,
|
||||
productName,
|
||||
color: item.color || '-',
|
||||
manufacturer
|
||||
});
|
||||
}
|
||||
|
||||
const group = groups.get(key)!;
|
||||
group.items.push(item);
|
||||
group.totalQty += item.quantity;
|
||||
// Ensure we take the max minStock found if they differ (though backend enforces consistency)
|
||||
if (item.minStock && item.minStock > group.minStock) {
|
||||
group.minStock = item.minStock;
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(groups.values());
|
||||
}, [filteredItems]);
|
||||
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
const newExpanded = new Set(expandedGroups);
|
||||
if (newExpanded.has(key)) {
|
||||
newExpanded.delete(key);
|
||||
} else {
|
||||
newExpanded.add(key);
|
||||
}
|
||||
setExpandedGroups(newExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-text-main flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-primary/20 flex items-center justify-center">
|
||||
<Package size={24} className="text-primary" />
|
||||
</div>
|
||||
Gestão de Estoque
|
||||
</h1>
|
||||
<p className="text-text-muted mt-2">Controle de Tintas e Diluentes</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handlePrintInventory}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border/40 text-text-main rounded-xl hover:bg-surface-hover transition-colors font-semibold"
|
||||
disabled={isPrintingInventory || items.length === 0}
|
||||
>
|
||||
<Printer size={20} />
|
||||
Inventário
|
||||
</button>
|
||||
{isAdmin() && activeTab === 'THINNER' && (
|
||||
<button
|
||||
onClick={() => setShowDiluentModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border/40 text-text-main rounded-xl hover:bg-surface-hover transition-colors font-semibold"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Cadastrar Diluente
|
||||
</button>
|
||||
)}
|
||||
{isAdmin() && (
|
||||
<button
|
||||
onClick={() => { setSelectedItem(null); setShowAddModal(true); }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-semibold"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Nova Entrada
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border/40 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('PAINT')}
|
||||
className={`px-6 py-3 font-medium text-sm transition-colors border-b-2 ${activeTab === 'PAINT'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
Tintas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('THINNER')}
|
||||
className={`px-6 py-3 font-medium text-sm transition-colors border-b-2 ${activeTab === 'THINNER'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
Diluentes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por RR, Lote ou Produto..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 bg-surface border border-border/40 rounded-xl text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-surface rounded-2xl border border-border/40 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-text-muted">Carregando...</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="p-8 text-center text-text-muted">Nenhum item encontrado.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-surface-soft border-b border-border/40">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase w-10"></th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Produto / Grupo</th>
|
||||
{activeTab === 'PAINT' && (
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Cor</th>
|
||||
)}
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Quantidade Total</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Lotes</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-bold text-text-muted uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{groupedItems.map((group, index) => {
|
||||
const groupKey = `${group.productName}-${group.color}-${index}`;
|
||||
const isExpanded = expandedGroups.has(groupKey);
|
||||
const isLowStock = group.minStock > 0 && group.totalQty < group.minStock;
|
||||
|
||||
return (
|
||||
<React.Fragment key={groupKey}>
|
||||
{/* Group Row */}
|
||||
<tr
|
||||
className={`hover:bg-surface-hover transition-colors cursor-pointer ${isExpanded ? 'bg-surface-hover/50' : ''}`}
|
||||
onClick={() => toggleGroup(groupKey)}
|
||||
>
|
||||
<td className="px-6 py-4 text-text-muted">
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-text-main text-base">{group.productName}</span>
|
||||
<span className="text-xs text-text-muted">{group.manufacturer}</span>
|
||||
</div>
|
||||
</td>
|
||||
{activeTab === 'PAINT' && (
|
||||
<td className="px-6 py-4 text-text-secondary">{group.color}</td>
|
||||
)}
|
||||
<td className="px-6 py-4 font-bold text-lg">
|
||||
<span className={isLowStock ? 'text-red-500 animate-blink flex items-center gap-2' : 'text-green-500'}>
|
||||
{isLowStock && <AlertCircle size={16} />}
|
||||
{group.totalQty.toFixed(1)} {group.unit}
|
||||
</span>
|
||||
{group.minStock > 0 && (
|
||||
<span className="block text-[10px] text-text-muted font-normal">
|
||||
Mín: {group.minStock} {group.unit}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-text-secondary">
|
||||
{group.items.length} lote(s)
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{/* Actions if needed for group? */}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded Item Rows */}
|
||||
{isExpanded && group.items.map(item => {
|
||||
const isExpired = item.expirationDate && new Date(item.expirationDate) < new Date();
|
||||
// Check individual item min stock for legacy reasons? No, rely on group.
|
||||
|
||||
return (
|
||||
<tr key={item._id} className="bg-surface-soft/50 hover:bg-surface-hover/80 transition-colors border-l-4 border-l-primary/20">
|
||||
<td className="px-6 py-3"></td> {/* Indentation */}
|
||||
<td className="px-6 py-3 font-mono text-xs text-text-muted">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>RR: <span className="text-text-main font-bold">{item.rrNumber}</span></div>
|
||||
{activeTab === 'THINNER' && (
|
||||
<div className="text-text-secondary">Lote: {item.batchNumber}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{activeTab === 'PAINT' && (
|
||||
<td className="px-6 py-3 text-xs text-text-secondary">
|
||||
Lote: {item.batchNumber}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-6 py-3 font-bold text-sm">
|
||||
{item.quantity} {item.unit}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
{activeTab === 'PAINT' ? (
|
||||
item.expirationDate ? (
|
||||
<span className={`text-xs ${isExpired ? 'text-red-500 font-bold' : 'text-text-secondary'}`}>
|
||||
Val: {new Date(item.expirationDate).toLocaleDateString()}
|
||||
</span>
|
||||
) : '-'
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right flex justify-end gap-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
|
||||
className="p-1.5 text-text-secondary hover:bg-surface-hover rounded-lg"
|
||||
title="Histórico"
|
||||
>
|
||||
<History size={16} />
|
||||
</button>
|
||||
{isAdmin() && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleOut(item); }}
|
||||
className="p-1.5 text-amber-500 hover:bg-amber-500/10 rounded-lg"
|
||||
title="Realizar Baixa"
|
||||
>
|
||||
<ArrowDown size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
||||
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(item._id!); }}
|
||||
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg"
|
||||
title="Excluir"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddModal && (
|
||||
<StockModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setSelectedItem(null); }}
|
||||
onSuccess={() => { fetchItems(); setShowAddModal(false); setSelectedItem(null); }}
|
||||
initialData={selectedItem || undefined}
|
||||
initialType={activeTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOutModal && selectedItem && (
|
||||
<StockOutModal
|
||||
isOpen={showOutModal}
|
||||
onClose={() => { setShowOutModal(false); setSelectedItem(null); }}
|
||||
onSuccess={() => { fetchItems(); setShowOutModal(false); setSelectedItem(null); }}
|
||||
item={selectedItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showHistoryModal && selectedItem && (
|
||||
<StockHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
onClose={() => { setShowHistoryModal(false); setSelectedItem(null); }}
|
||||
item={selectedItem}
|
||||
onUpdate={fetchItems}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPrintingInventory && (
|
||||
<StockInventoryReport
|
||||
items={filteredItems}
|
||||
movements={allMovements}
|
||||
logoUrl={logoUrl}
|
||||
reportType={activeTab}
|
||||
/>
|
||||
)}
|
||||
{showDiluentModal && (
|
||||
<DiluentListModal
|
||||
isOpen={showDiluentModal}
|
||||
onClose={() => setShowDiluentModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user