Unify material supplier and balance controls

This commit is contained in:
Cauê Faleiros
2026-08-24 16:43:03 -03:00
parent 7e18772a8a
commit 11443a9202
5 changed files with 230 additions and 5 deletions

View File

@@ -10,6 +10,7 @@ const {
deleteReceipt,
getSupplySummary,
listFabricPlans,
listKnownSuppliers,
saveMaterialDetails,
listLots,
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) => {
try {
res.json(await listReceipts());

View File

@@ -158,6 +158,26 @@ const listMaterialDetails = async () => {
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 normalizedKey = normalizeKey(materialKey);
if (!normalizedKey) throw createValidationError('Material inválido para edição.');
@@ -1062,6 +1082,7 @@ module.exports = {
buildPurchaseNeeds,
listFabricPlans,
listMaterialDetails,
listKnownSuppliers,
listLots,
listMovements,
listPurchaseNeeds,

View 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;

View File

@@ -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 (
materialKey: string,
payload: SupplyMaterialDetailsPayload

View File

@@ -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>
);
};