Unify material supplier and balance controls
This commit is contained in:
@@ -10,6 +10,7 @@ const {
|
|||||||
deleteReceipt,
|
deleteReceipt,
|
||||||
getSupplySummary,
|
getSupplySummary,
|
||||||
listFabricPlans,
|
listFabricPlans,
|
||||||
|
listKnownSuppliers,
|
||||||
saveMaterialDetails,
|
saveMaterialDetails,
|
||||||
listLots,
|
listLots,
|
||||||
listMovements,
|
listMovements,
|
||||||
@@ -27,6 +28,14 @@ router.get('/supply', verifyToken, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/supply/suppliers', verifyToken, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await listKnownSuppliers());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/supply/receipts', verifyToken, async (req, res, next) => {
|
router.get('/supply/receipts', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.json(await listReceipts());
|
res.json(await listReceipts());
|
||||||
|
|||||||
@@ -158,6 +158,26 @@ const listMaterialDetails = async () => {
|
|||||||
return result.rows.map(mapMaterialDetails);
|
return result.rows.map(mapMaterialDetails);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listKnownSuppliers = async () => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT DISTINCT supplier
|
||||||
|
FROM (
|
||||||
|
SELECT preferred_supplier AS supplier FROM supply_material_details
|
||||||
|
UNION ALL
|
||||||
|
SELECT supplier FROM supply_lots
|
||||||
|
UNION ALL
|
||||||
|
SELECT supplier FROM supply_receipts
|
||||||
|
UNION ALL
|
||||||
|
SELECT supplier FROM supply_fabric_plans
|
||||||
|
UNION ALL
|
||||||
|
SELECT supplier FROM production_yield_supplier_recommendations
|
||||||
|
) sources
|
||||||
|
WHERE NULLIF(BTRIM(supplier), '') IS NOT NULL
|
||||||
|
ORDER BY supplier;
|
||||||
|
`);
|
||||||
|
return result.rows.map(row => row.supplier);
|
||||||
|
};
|
||||||
|
|
||||||
const saveMaterialDetails = async (materialKey, payload) => {
|
const saveMaterialDetails = async (materialKey, payload) => {
|
||||||
const normalizedKey = normalizeKey(materialKey);
|
const normalizedKey = normalizeKey(materialKey);
|
||||||
if (!normalizedKey) throw createValidationError('Material inválido para edição.');
|
if (!normalizedKey) throw createValidationError('Material inválido para edição.');
|
||||||
@@ -1062,6 +1082,7 @@ module.exports = {
|
|||||||
buildPurchaseNeeds,
|
buildPurchaseNeeds,
|
||||||
listFabricPlans,
|
listFabricPlans,
|
||||||
listMaterialDetails,
|
listMaterialDetails,
|
||||||
|
listKnownSuppliers,
|
||||||
listLots,
|
listLots,
|
||||||
listMovements,
|
listMovements,
|
||||||
listPurchaseNeeds,
|
listPurchaseNeeds,
|
||||||
|
|||||||
125
src/components/StockGraphsBalanceModal.tsx
Normal file
125
src/components/StockGraphsBalanceModal.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { History, RotateCcw, Save, X } from 'lucide-react';
|
||||||
|
import { deleteGraphsStockBalance, fetchGraphsStockBalanceHistory, saveGraphsStockBalance } from '../dataService';
|
||||||
|
import type { GraphsStockBalanceHistoryItem, StockData } from '../types';
|
||||||
|
|
||||||
|
const formatNumber = (value: number, maximumFractionDigits = 0) => (
|
||||||
|
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDate = (value: string | null | undefined) => {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? '—' : new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' }).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
type StockGraphsBalanceModalProps = {
|
||||||
|
stock: StockData;
|
||||||
|
title?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (stock: StockData) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StockGraphsBalanceModal = ({ stock, title, onClose, onSaved }: StockGraphsBalanceModalProps) => {
|
||||||
|
const [balance, setBalance] = useState(stock.graphs_balance === null || stock.graphs_balance === undefined ? '' : String(stock.graphs_balance));
|
||||||
|
const [reason, setReason] = useState(stock.graphs_balance_reason || 'Conferência física de estoque');
|
||||||
|
const [effectiveAt, setEffectiveAt] = useState(stock.graphs_balance_effective_at?.slice(0, 10) || new Date().toISOString().slice(0, 10));
|
||||||
|
const [history, setHistory] = useState<GraphsStockBalanceHistoryItem[]>([]);
|
||||||
|
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async () => {
|
||||||
|
setIsLoadingHistory(true);
|
||||||
|
try {
|
||||||
|
setHistory(await fetchGraphsStockBalanceHistory(stock.produto_id));
|
||||||
|
} catch (historyError) {
|
||||||
|
setError(historyError instanceof Error ? historyError.message : 'Não foi possível carregar o histórico.');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingHistory(false);
|
||||||
|
}
|
||||||
|
}, [stock.produto_id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => { void loadHistory(); }, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [loadHistory]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const normalizedBalance = Number(balance.replace(',', '.'));
|
||||||
|
if (!Number.isFinite(normalizedBalance)) {
|
||||||
|
setError('Informe um saldo físico válido.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const saved = await saveGraphsStockBalance(stock.produto_id, { balance: normalizedBalance, reason, effectiveAt });
|
||||||
|
onSaved(saved);
|
||||||
|
setBalance(String(saved.graphs_balance ?? normalizedBalance));
|
||||||
|
await loadHistory();
|
||||||
|
} catch (saveError) {
|
||||||
|
setError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar o saldo Graphs.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!window.confirm('Remover o saldo Graphs e voltar a usar o saldo importado do Tiny no planejamento?')) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const saved = await deleteGraphsStockBalance(stock.produto_id);
|
||||||
|
onSaved(saved);
|
||||||
|
setBalance('');
|
||||||
|
await loadHistory();
|
||||||
|
} catch (removeError) {
|
||||||
|
setError(removeError instanceof Error ? removeError.message : 'Não foi possível remover o saldo Graphs.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const planningBalance = Math.max(0, Number(stock.planning_balance ?? stock.saldo));
|
||||||
|
const materialName = title || stock.nome;
|
||||||
|
|
||||||
|
return <div className="fixed inset-0 z-[60] flex h-dvh items-center justify-center overflow-y-auto bg-black/75 p-4 backdrop-blur-md" role="dialog" aria-modal="true" aria-label={`Saldo Graphs de ${materialName}`} onClick={onClose}>
|
||||||
|
<div className="w-full max-w-2xl rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-dark-muted">Material #{stock.produto_id}</p>
|
||||||
|
<h2 className="mt-1 truncate text-lg font-bold text-dark-text" title={materialName}>{materialName}</h2>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-dark-muted">O saldo Tiny permanece preservado para auditoria. O saldo Graphs passa a ser a base do planejamento.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} title="Fechar" aria-label="Fechar" className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"><X className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 p-5 sm:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Saldo Tiny</p><p className="mt-2 text-lg font-bold text-red-300">{formatNumber(Number(stock.saldo), 2)} un.</p></div>
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Saldo Graphs</p><p className="mt-2 text-lg font-bold text-brand-primary">{stock.graphs_balance === null || stock.graphs_balance === undefined ? 'Não informado' : `${formatNumber(Number(stock.graphs_balance), 2)} un.`}</p></div>
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Disponível no plano</p><p className="mt-2 text-lg font-bold text-emerald-300">{formatNumber(planningBalance, 2)} un.</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 border-t border-dark-border p-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo físico no Graphs</span><input type="text" inputMode="decimal" value={balance} onChange={(event) => setBalance(event.target.value)} placeholder="Ex.: 850,50" className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary" /></label>
|
||||||
|
<label className="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Data da conferência</span><input type="date" value={effectiveAt} onChange={(event) => setEffectiveAt(event.target.value)} className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary" /></label>
|
||||||
|
<label className="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Motivo</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder="Ex.: inventário físico do depósito" className="w-full resize-none rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary" /></label>
|
||||||
|
{error && <p className="rounded-lg border border-red-400/30 bg-red-400/10 px-3 py-2 text-xs font-bold text-red-300">{error}</p>}
|
||||||
|
<div className="flex flex-wrap justify-between gap-2"><button type="button" onClick={() => void remove()} disabled={isSaving || stock.graphs_balance === null || stock.graphs_balance === undefined} className="inline-flex h-10 items-center gap-2 rounded-lg border border-dark-border px-3 text-sm font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><RotateCcw className="h-4 w-4" /> Usar Tiny</button><button type="button" onClick={() => void save()} disabled={isSaving} className="inline-flex h-10 items-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><Save className="h-4 w-4" /> {isSaving ? 'Salvando...' : 'Salvar saldo Graphs'}</button></div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-dark-border">
|
||||||
|
<div className="flex items-center gap-2 border-b border-dark-border bg-dark-input/50 px-4 py-3"><History className="h-4 w-4 text-brand-primary" /><h3 className="text-sm font-bold text-dark-text">Histórico de alterações</h3></div>
|
||||||
|
<div className="max-h-80 divide-y divide-dark-border overflow-y-auto">
|
||||||
|
{isLoadingHistory && <p className="p-4 text-sm font-semibold text-dark-muted">Carregando histórico...</p>}
|
||||||
|
{!isLoadingHistory && !history.length && <p className="p-4 text-sm font-semibold text-dark-muted">Nenhuma alteração manual registrada.</p>}
|
||||||
|
{history.map(item => <div key={item.id} className="p-4"><div className="flex items-center justify-between gap-3"><p className="text-sm font-bold text-dark-text">{item.action === 'removed' ? 'Saldo Graphs removido' : `${formatNumber(Number(item.graphs_balance || 0), 2)} un.`}</p><span className="text-xs font-semibold text-dark-muted">{formatDate(item.created_at)}</span></div><p className="mt-1 text-xs font-semibold text-dark-muted">Tiny: {formatNumber(Number(item.tiny_balance || 0), 2)} un. · referência {formatDate(item.effective_at)}</p>{item.reason && <p className="mt-2 text-xs font-medium text-dark-text">{item.reason}</p>}{item.changed_by && <p className="mt-1 text-[11px] font-semibold text-dark-muted">Por {item.changed_by}</p>}</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockGraphsBalanceModal;
|
||||||
@@ -525,6 +525,12 @@ export const fetchSupplySummary = async (): Promise<SupplySummary> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchSupplySuppliers = async (): Promise<string[]> => {
|
||||||
|
const response = await authFetch('/supply/suppliers');
|
||||||
|
if (!response.ok) throw new Error('Não foi possível carregar os fornecedores cadastrados.');
|
||||||
|
return response.json() as Promise<string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
export const saveSupplyMaterialDetails = async (
|
export const saveSupplyMaterialDetails = async (
|
||||||
materialKey: string,
|
materialKey: string,
|
||||||
payload: SupplyMaterialDetailsPayload
|
payload: SupplyMaterialDetailsPayload
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ import { buildSkuEditPath } from '../catalogLinks';
|
|||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
|
import StockGraphsBalanceModal from '../components/StockGraphsBalanceModal';
|
||||||
import { classifyCutFamily } from '../analytics/cutting';
|
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 { parseProductName } from '../productParsing';
|
||||||
import { resolveProductType, type ProductTypeKey } from '../productClassification';
|
import { resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||||
import { getPlanningStock } from '../planningStock';
|
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 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>>) => {
|
const exportCsv = (filename: string, rows: Array<Record<string, string | number | null>>) => {
|
||||||
if (!rows.length) return;
|
if (!rows.length) return;
|
||||||
@@ -2314,12 +2316,18 @@ const getMaterialRegistrationPath = (material: string) => buildSkuEditPath({
|
|||||||
|
|
||||||
const MaterialDetailsModal = ({
|
const MaterialDetailsModal = ({
|
||||||
need,
|
need,
|
||||||
|
linkedStock,
|
||||||
|
supplierOptions,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
|
onEditGraphsBalance,
|
||||||
}: {
|
}: {
|
||||||
need: SupplyPurchaseNeed;
|
need: SupplyPurchaseNeed;
|
||||||
|
linkedStock: StockData | null;
|
||||||
|
supplierOptions: string[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (payload: { displayName: string; preferredSupplier: string; notes: string }) => Promise<void>;
|
onSave: (payload: { displayName: string; preferredSupplier: string; notes: string }) => Promise<void>;
|
||||||
|
onEditGraphsBalance: (stock: StockData) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
displayName: need.material,
|
displayName: need.material,
|
||||||
@@ -2363,8 +2371,19 @@ const MaterialDetailsModal = ({
|
|||||||
</label>
|
</label>
|
||||||
<label className="block text-xs font-bold text-dark-muted">
|
<label className="block text-xs font-bold text-dark-muted">
|
||||||
Fornecedor preferencial
|
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>
|
</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">
|
<label className="block text-xs font-bold text-dark-muted">
|
||||||
Observações operacionais
|
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" />
|
<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 PurchaseNeedsScreen = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||||
|
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
|
||||||
|
const [supplierOptions, setSupplierOptions] = useState<string[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [editingMaterial, setEditingMaterial] = useState<SupplyPurchaseNeed | null>(null);
|
const [editingMaterial, setEditingMaterial] = useState<SupplyPurchaseNeed | null>(null);
|
||||||
|
const [editingGraphsBalance, setEditingGraphsBalance] = useState<StockData | null>(null);
|
||||||
const requestedTab = searchParams.get('tab');
|
const requestedTab = searchParams.get('tab');
|
||||||
const requestedFilter = searchParams.get('filter');
|
const requestedFilter = searchParams.get('filter');
|
||||||
const activeTab: PurchaseNeedTab = requestedTab === 'services' ? 'services' : 'materials';
|
const activeTab: PurchaseNeedTab = requestedTab === 'services' ? 'services' : 'materials';
|
||||||
@@ -2401,8 +2423,14 @@ const PurchaseNeedsScreen = () => {
|
|||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
const loadSummary = async () => {
|
const loadSummary = async () => {
|
||||||
const nextSummary = await fetchSupplySummary();
|
const [nextSummary, nextStockSnapshot, nextSuppliers] = await Promise.all([
|
||||||
|
fetchSupplySummary(),
|
||||||
|
fetchStock(),
|
||||||
|
fetchSupplySuppliers(),
|
||||||
|
]);
|
||||||
setSummary(nextSummary);
|
setSummary(nextSummary);
|
||||||
|
setStockSnapshot(nextStockSnapshot);
|
||||||
|
setSupplierOptions(nextSuppliers);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2411,8 +2439,16 @@ const PurchaseNeedsScreen = () => {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const nextSummary = await fetchSupplySummary();
|
const [nextSummary, nextStockSnapshot, nextSuppliers] = await Promise.all([
|
||||||
if (isMounted) setSummary(nextSummary);
|
fetchSupplySummary(),
|
||||||
|
fetchStock(),
|
||||||
|
fetchSupplySuppliers(),
|
||||||
|
]);
|
||||||
|
if (isMounted) {
|
||||||
|
setSummary(nextSummary);
|
||||||
|
setStockSnapshot(nextStockSnapshot);
|
||||||
|
setSupplierOptions(nextSuppliers);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isMounted) setIsLoading(false);
|
if (isMounted) setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -2459,6 +2495,23 @@ const PurchaseNeedsScreen = () => {
|
|||||||
const visibleNeeds = activeNeeds.filter(need => (
|
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)
|
!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 totalPages = Math.ceil(visibleNeeds.length / itemsPerPage);
|
||||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||||
@@ -2581,13 +2634,24 @@ const PurchaseNeedsScreen = () => {
|
|||||||
<MaterialDetailsModal
|
<MaterialDetailsModal
|
||||||
key={editingMaterial.materialKey}
|
key={editingMaterial.materialKey}
|
||||||
need={editingMaterial}
|
need={editingMaterial}
|
||||||
|
linkedStock={linkedEditingStock}
|
||||||
|
supplierOptions={supplierOptions}
|
||||||
onClose={() => setEditingMaterial(null)}
|
onClose={() => setEditingMaterial(null)}
|
||||||
|
onEditGraphsBalance={setEditingGraphsBalance}
|
||||||
onSave={async (payload) => {
|
onSave={async (payload) => {
|
||||||
await saveSupplyMaterialDetails(editingMaterial.materialKey, payload);
|
await saveSupplyMaterialDetails(editingMaterial.materialKey, payload);
|
||||||
await loadSummary();
|
await loadSummary();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{editingGraphsBalance && (
|
||||||
|
<StockGraphsBalanceModal
|
||||||
|
stock={editingGraphsBalance}
|
||||||
|
title={editingMaterial?.material || editingGraphsBalance.nome}
|
||||||
|
onClose={() => setEditingGraphsBalance(null)}
|
||||||
|
onSaved={handleGraphsBalanceSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user