Unify material supplier and balance controls
This commit is contained in:
@@ -29,8 +29,9 @@ import { buildSkuEditPath } from '../catalogLinks';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import StockGraphsBalanceModal from '../components/StockGraphsBalanceModal';
|
||||
import { classifyCutFamily } from '../analytics/cutting';
|
||||
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplySummary, isSuperAdmin, saveSupplyMaterialDetails } from '../dataService';
|
||||
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplySummary, fetchSupplySuppliers, isSuperAdmin, saveSupplyMaterialDetails } from '../dataService';
|
||||
import { parseProductName } from '../productParsing';
|
||||
import { resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
@@ -86,6 +87,7 @@ const formatDateTime = (value: string | null) => {
|
||||
};
|
||||
|
||||
const normalizeSearch = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||
const normalizeMaterialKey = (value: string) => normalizeSearch(value).replace(/\s+/g, ' ').trim();
|
||||
|
||||
const exportCsv = (filename: string, rows: Array<Record<string, string | number | null>>) => {
|
||||
if (!rows.length) return;
|
||||
@@ -2314,12 +2316,18 @@ const getMaterialRegistrationPath = (material: string) => buildSkuEditPath({
|
||||
|
||||
const MaterialDetailsModal = ({
|
||||
need,
|
||||
linkedStock,
|
||||
supplierOptions,
|
||||
onClose,
|
||||
onSave,
|
||||
onEditGraphsBalance,
|
||||
}: {
|
||||
need: SupplyPurchaseNeed;
|
||||
linkedStock: StockData | null;
|
||||
supplierOptions: string[];
|
||||
onClose: () => void;
|
||||
onSave: (payload: { displayName: string; preferredSupplier: string; notes: string }) => Promise<void>;
|
||||
onEditGraphsBalance: (stock: StockData) => void;
|
||||
}) => {
|
||||
const [form, setForm] = useState({
|
||||
displayName: need.material,
|
||||
@@ -2363,8 +2371,19 @@ const MaterialDetailsModal = ({
|
||||
</label>
|
||||
<label className="block text-xs font-bold text-dark-muted">
|
||||
Fornecedor preferencial
|
||||
<input value={form.preferredSupplier} onChange={(event) => setForm(current => ({ ...current, preferredSupplier: event.target.value }))} placeholder="Opcional" className={`${inputClassName} mt-1`} />
|
||||
<input list="known-suppliers" value={form.preferredSupplier} onChange={(event) => setForm(current => ({ ...current, preferredSupplier: event.target.value }))} placeholder="Selecione ou informe um fornecedor" className={`${inputClassName} mt-1`} />
|
||||
<datalist id="known-suppliers">{supplierOptions.map(supplier => <option key={supplier} value={supplier} />)}</datalist>
|
||||
<span className="mt-1 block text-[11px] font-semibold text-dark-muted">Sugestões carregadas do histórico de fornecedores e recebimentos.</span>
|
||||
</label>
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo físico no Graphs</p>
|
||||
{linkedStock ? <><p className="mt-1 text-sm font-bold text-brand-primary">{linkedStock.graphs_balance === null || linkedStock.graphs_balance === undefined ? 'Não informado' : `${new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(Number(linkedStock.graphs_balance))} un.`}</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">Tiny: {new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(Number(linkedStock.saldo))} un. · usado no plano: {new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(Math.max(0, Number(linkedStock.planning_balance ?? linkedStock.saldo)))} un.</p></> : <p className="mt-1 text-xs font-semibold text-dark-muted">Este material ainda não possui saldo importado do Tiny para conferência.</p>}
|
||||
</div>
|
||||
{linkedStock && <button type="button" onClick={() => onEditGraphsBalance(linkedStock)} className="inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><Pencil className="h-3.5 w-3.5" /> Ajustar saldo</button>}
|
||||
</div>
|
||||
</div>
|
||||
<label className="block text-xs font-bold text-dark-muted">
|
||||
Observações operacionais
|
||||
<textarea value={form.notes} onChange={(event) => setForm(current => ({ ...current, notes: event.target.value }))} placeholder="Opcional" className="mt-1 min-h-24 w-full resize-y rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" />
|
||||
@@ -2389,9 +2408,12 @@ const MaterialDetailsModal = ({
|
||||
const PurchaseNeedsScreen = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
|
||||
const [supplierOptions, setSupplierOptions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [editingMaterial, setEditingMaterial] = useState<SupplyPurchaseNeed | null>(null);
|
||||
const [editingGraphsBalance, setEditingGraphsBalance] = useState<StockData | null>(null);
|
||||
const requestedTab = searchParams.get('tab');
|
||||
const requestedFilter = searchParams.get('filter');
|
||||
const activeTab: PurchaseNeedTab = requestedTab === 'services' ? 'services' : 'materials';
|
||||
@@ -2401,8 +2423,14 @@ const PurchaseNeedsScreen = () => {
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
const loadSummary = async () => {
|
||||
const nextSummary = await fetchSupplySummary();
|
||||
const [nextSummary, nextStockSnapshot, nextSuppliers] = await Promise.all([
|
||||
fetchSupplySummary(),
|
||||
fetchStock(),
|
||||
fetchSupplySuppliers(),
|
||||
]);
|
||||
setSummary(nextSummary);
|
||||
setStockSnapshot(nextStockSnapshot);
|
||||
setSupplierOptions(nextSuppliers);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2411,8 +2439,16 @@ const PurchaseNeedsScreen = () => {
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const nextSummary = await fetchSupplySummary();
|
||||
if (isMounted) setSummary(nextSummary);
|
||||
const [nextSummary, nextStockSnapshot, nextSuppliers] = await Promise.all([
|
||||
fetchSupplySummary(),
|
||||
fetchStock(),
|
||||
fetchSupplySuppliers(),
|
||||
]);
|
||||
if (isMounted) {
|
||||
setSummary(nextSummary);
|
||||
setStockSnapshot(nextStockSnapshot);
|
||||
setSupplierOptions(nextSuppliers);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) setIsLoading(false);
|
||||
}
|
||||
@@ -2459,6 +2495,23 @@ const PurchaseNeedsScreen = () => {
|
||||
const visibleNeeds = activeNeeds.filter(need => (
|
||||
!normalizedSearch || normalizeSearch(`${need.material} ${getNeedDisplayName(need)} ${need.suppliers.join(' ')} ${need.colors.join(' ')} ${(need.products || []).map(product => `${product.productId} ${product.name}`).join(' ')}`).includes(normalizedSearch)
|
||||
));
|
||||
const stocksByMaterialKey = useMemo(() => {
|
||||
const index = new Map<string, StockData>();
|
||||
stockSnapshot.forEach(stock => {
|
||||
index.set(normalizeMaterialKey(stock.nome), stock);
|
||||
index.set(normalizeMaterialKey(stock.produto_id), stock);
|
||||
});
|
||||
return index;
|
||||
}, [stockSnapshot]);
|
||||
const linkedEditingStock = editingMaterial
|
||||
? stocksByMaterialKey.get(normalizeMaterialKey(editingMaterial.materialKey)) || stocksByMaterialKey.get(normalizeMaterialKey(editingMaterial.material)) || null
|
||||
: null;
|
||||
const handleGraphsBalanceSaved = (updatedStock: StockData) => {
|
||||
setStockSnapshot(current => current.map(stock => (
|
||||
stock.produto_id === updatedStock.produto_id ? updatedStock : stock
|
||||
)));
|
||||
void loadSummary();
|
||||
};
|
||||
const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
@@ -2581,13 +2634,24 @@ const PurchaseNeedsScreen = () => {
|
||||
<MaterialDetailsModal
|
||||
key={editingMaterial.materialKey}
|
||||
need={editingMaterial}
|
||||
linkedStock={linkedEditingStock}
|
||||
supplierOptions={supplierOptions}
|
||||
onClose={() => setEditingMaterial(null)}
|
||||
onEditGraphsBalance={setEditingGraphsBalance}
|
||||
onSave={async (payload) => {
|
||||
await saveSupplyMaterialDetails(editingMaterial.materialKey, payload);
|
||||
await loadSummary();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{editingGraphsBalance && (
|
||||
<StockGraphsBalanceModal
|
||||
stock={editingGraphsBalance}
|
||||
title={editingMaterial?.material || editingGraphsBalance.nome}
|
||||
onClose={() => setEditingGraphsBalance(null)}
|
||||
onSaved={handleGraphsBalanceSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user