diff --git a/backend/routes/supplyRoutes.js b/backend/routes/supplyRoutes.js index 4172b9c..a1471e1 100644 --- a/backend/routes/supplyRoutes.js +++ b/backend/routes/supplyRoutes.js @@ -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()); diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index bf43da2..856f886 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -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, diff --git a/src/components/StockGraphsBalanceModal.tsx b/src/components/StockGraphsBalanceModal.tsx new file mode 100644 index 0000000..5f8c5fc --- /dev/null +++ b/src/components/StockGraphsBalanceModal.tsx @@ -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([]); + 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
+
event.stopPropagation()}> +
+
+

Material #{stock.produto_id}

+

{materialName}

+

O saldo Tiny permanece preservado para auditoria. O saldo Graphs passa a ser a base do planejamento.

+
+ +
+ +
+

Saldo Tiny

{formatNumber(Number(stock.saldo), 2)} un.

+

Saldo Graphs

{stock.graphs_balance === null || stock.graphs_balance === undefined ? 'Não informado' : `${formatNumber(Number(stock.graphs_balance), 2)} un.`}

+

Disponível no plano

{formatNumber(planningBalance, 2)} un.

+
+ +
+
+ + +