Add audited Graphs stock balances
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s

This commit is contained in:
Cauê Faleiros
2026-08-24 14:34:25 -03:00
parent 71c80fd5d4
commit 7e18772a8a
7 changed files with 450 additions and 20 deletions

View File

@@ -97,6 +97,35 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {}); `).catch(() => {});
// Tiny/Olist remains the imported source balance. Graphs physical balances
// are stored separately so planning can use a verified count without
// overwriting the audit value received from Tiny.
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_graphs_balances (
produto_id VARCHAR(100) PRIMARY KEY,
balance NUMERIC(14, 4) NOT NULL,
reason TEXT NOT NULL,
effective_at DATE,
updated_by VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_graphs_balance_history (
id SERIAL PRIMARY KEY,
produto_id VARCHAR(100) NOT NULL,
tiny_balance NUMERIC(14, 4),
graphs_balance NUMERIC(14, 4),
reason TEXT,
effective_at DATE,
action VARCHAR(20) NOT NULL DEFAULT 'set',
changed_by VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS stock_campaign_queue ( CREATE TABLE IF NOT EXISTS stock_campaign_queue (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -675,6 +704,7 @@ const initDB = async () => {
}); });
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_graphs_balance_history_product_created ON stock_graphs_balance_history (produto_id, created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_status ON production_orders (status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_status ON production_orders (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);

View File

@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth'); const { authenticateAPIKey, verifyToken } = require('../auth');
const { listStock, upsertStockItems } = require('../services/stockService'); const { listGraphsBalanceHistory, listStock, removeGraphsBalance, saveGraphsBalance, upsertStockItems } = require('../services/stockService');
const router = express.Router(); const router = express.Router();
@@ -13,6 +13,35 @@ router.get('/stock', verifyToken, async (req, res) => {
} }
}); });
router.get('/stock/:productId/graphs-balance-history', verifyToken, async (req, res) => {
try {
res.json(await listGraphsBalanceHistory(req.params.productId));
} catch (error) {
console.error('Error fetching Graphs stock balance history:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.put('/stock/:productId/graphs-balance', verifyToken, async (req, res) => {
try {
const balance = await saveGraphsBalance(req.params.productId, req.body, req.user?.email);
res.json(balance);
} catch (error) {
console.error('Error saving Graphs stock balance:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.delete('/stock/:productId/graphs-balance', verifyToken, async (req, res) => {
try {
const balance = await removeGraphsBalance(req.params.productId, req.user?.email);
res.json(balance);
} catch (error) {
console.error('Error removing Graphs stock balance:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/stock', authenticateAPIKey, async (req, res) => { router.post('/stock', authenticateAPIKey, async (req, res) => {
res.status(201).json({ message: 'Stock data received, processing in background' }); res.status(201).json({ message: 'Stock data received, processing in background' });

View File

@@ -5,10 +5,180 @@ const { enqueueStockCampaignItem } = require('./campaignService');
const POSITIVE_STOCK_DELTA_THRESHOLD = 1; const POSITIVE_STOCK_DELTA_THRESHOLD = 1;
const listStock = async () => { const listStock = async () => {
const result = await pool.query('SELECT * FROM stock'); const result = await pool.query(`
SELECT
stock.*,
graphs.balance::float8 AS graphs_balance,
graphs.reason AS graphs_balance_reason,
graphs.effective_at AS graphs_balance_effective_at,
graphs.updated_by AS graphs_balance_updated_by,
graphs.updated_at AS graphs_balance_updated_at,
COALESCE(graphs.balance, stock.saldo)::float8 AS planning_balance
FROM stock
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
ORDER BY stock.nome, stock.produto_id;
`);
return result.rows; return result.rows;
}; };
const getStockBalance = async (produtoId, client = pool) => {
const result = await client.query(`
SELECT
stock.*,
graphs.balance::float8 AS graphs_balance,
graphs.reason AS graphs_balance_reason,
graphs.effective_at AS graphs_balance_effective_at,
graphs.updated_by AS graphs_balance_updated_by,
graphs.updated_at AS graphs_balance_updated_at,
COALESCE(graphs.balance, stock.saldo)::float8 AS planning_balance
FROM stock
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE stock.produto_id = $1;
`, [produtoId]);
return result.rows[0] || null;
};
const listGraphsBalanceHistory = async (produtoId) => {
const result = await pool.query(`
SELECT
id,
produto_id,
tiny_balance::float8 AS tiny_balance,
graphs_balance::float8 AS graphs_balance,
reason,
effective_at,
action,
changed_by,
created_at
FROM stock_graphs_balance_history
WHERE produto_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 20;
`, [produtoId]);
return result.rows;
};
const normalizeGraphsBalancePayload = (payload) => {
const balance = Number(payload?.balance);
const reason = String(payload?.reason || '').trim();
const effectiveAt = String(payload?.effectiveAt || '').trim() || null;
if (!Number.isFinite(balance)) {
const error = new Error('Informe um saldo físico válido para o Graphs.');
error.statusCode = 400;
throw error;
}
if (!reason) {
const error = new Error('Informe o motivo da conferência de saldo.');
error.statusCode = 400;
throw error;
}
if (effectiveAt && !/^\d{4}-\d{2}-\d{2}$/.test(effectiveAt)) {
const error = new Error('Informe uma data de referência válida.');
error.statusCode = 400;
throw error;
}
return { balance, reason, effectiveAt };
};
const saveGraphsBalance = async (produtoId, payload, updatedBy) => {
const normalizedProductId = String(produtoId || '').trim();
if (!normalizedProductId) {
const error = new Error('Material inválido para ajuste de saldo.');
error.statusCode = 400;
throw error;
}
const { balance, reason, effectiveAt } = normalizeGraphsBalancePayload(payload);
const client = await pool.connect();
try {
await client.query('BEGIN');
const stock = await client.query('SELECT saldo FROM stock WHERE produto_id = $1 FOR UPDATE', [normalizedProductId]);
if (!stock.rows[0]) {
const error = new Error('Material não encontrado no estoque importado.');
error.statusCode = 404;
throw error;
}
await client.query(`
INSERT INTO stock_graphs_balances (produto_id, balance, reason, effective_at, updated_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (produto_id) DO UPDATE SET
balance = EXCLUDED.balance,
reason = EXCLUDED.reason,
effective_at = EXCLUDED.effective_at,
updated_by = EXCLUDED.updated_by,
updated_at = CURRENT_TIMESTAMP;
`, [normalizedProductId, balance, reason, effectiveAt, updatedBy || null]);
await client.query(`
INSERT INTO stock_graphs_balance_history (
produto_id, tiny_balance, graphs_balance, reason, effective_at, action, changed_by
) VALUES ($1, $2, $3, $4, $5, 'set', $6);
`, [normalizedProductId, stock.rows[0].saldo, balance, reason, effectiveAt, updatedBy || null]);
const saved = await getStockBalance(normalizedProductId, client);
await client.query('COMMIT');
return saved;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const removeGraphsBalance = async (produtoId, updatedBy) => {
const normalizedProductId = String(produtoId || '').trim();
const client = await pool.connect();
try {
await client.query('BEGIN');
const current = await client.query(`
SELECT stock.saldo, graphs.balance, graphs.reason, graphs.effective_at
FROM stock
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE stock.produto_id = $1
FOR UPDATE OF stock;
`, [normalizedProductId]);
if (!current.rows[0]) {
const error = new Error('Material não encontrado no estoque importado.');
error.statusCode = 404;
throw error;
}
if (current.rows[0].balance === null) {
const error = new Error('Não há saldo Graphs para remover.');
error.statusCode = 404;
throw error;
}
await client.query('DELETE FROM stock_graphs_balances WHERE produto_id = $1', [normalizedProductId]);
await client.query(`
INSERT INTO stock_graphs_balance_history (
produto_id, tiny_balance, graphs_balance, reason, effective_at, action, changed_by
) VALUES ($1, $2, $3, $4, $5, 'removed', $6);
`, [
normalizedProductId,
current.rows[0].saldo,
current.rows[0].balance,
current.rows[0].reason,
current.rows[0].effective_at,
updatedBy || null
]);
const saved = await getStockBalance(normalizedProductId, client);
await client.query('COMMIT');
return saved;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const upsertStockItems = async (payload) => { const upsertStockItems = async (payload) => {
const client = await pool.connect(); const client = await pool.connect();
@@ -52,5 +222,8 @@ const upsertStockItems = async (payload) => {
module.exports = { module.exports = {
listStock, listStock,
listGraphsBalanceHistory,
saveGraphsBalance,
removeGraphsBalance,
upsertStockItems upsertStockItems
}; };

View File

@@ -285,9 +285,10 @@ const listProjectDemandRows = async () => {
COALESCE(period_orders.produto_id, stock.produto_id) AS product_id, COALESCE(period_orders.produto_id, stock.produto_id) AS product_id,
COALESCE(NULLIF(period_orders.product_name, ''), NULLIF(stock.nome, ''), 'Produto sem nome') AS product_name, COALESCE(NULLIF(period_orders.product_name, ''), NULLIF(stock.nome, ''), 'Produto sem nome') AS product_name,
COALESCE(period_orders.quantity_sold, 0)::numeric AS quantity_sold, COALESCE(period_orders.quantity_sold, 0)::numeric AS quantity_sold,
COALESCE(stock.saldo, 0)::numeric AS stock_quantity COALESCE(graphs.balance, stock.saldo, 0)::numeric AS stock_quantity
FROM period_orders FROM period_orders
FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE COALESCE(period_orders.produto_id, stock.produto_id, '') <> '' WHERE COALESCE(period_orders.produto_id, stock.produto_id, '') <> ''
ORDER BY quantity_sold DESC, product_name; ORDER BY quantity_sold DESC, product_name;
`, [DEFAULT_SUPPLY_LOOKBACK_DAYS]); `, [DEFAULT_SUPPLY_LOOKBACK_DAYS]);
@@ -356,9 +357,15 @@ const listCompositionRows = async () => {
const listTinyStockRows = async () => { const listTinyStockRows = async () => {
const result = await pool.query(` const result = await pool.query(`
SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo SELECT
stock.produto_id,
stock.nome,
COALESCE(stock.saldo, 0)::numeric AS tiny_saldo,
graphs.balance::numeric AS graphs_balance,
COALESCE(graphs.balance, stock.saldo, 0)::numeric AS saldo
FROM stock FROM stock
WHERE COALESCE(produto_id, '') <> '' OR COALESCE(nome, '') <> ''; LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE COALESCE(stock.produto_id, '') <> '' OR COALESCE(stock.nome, '') <> '';
`); `);
return result.rows; return result.rows;
}; };

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -139,6 +139,30 @@ export const fetchStock = async (): Promise<StockData[]> => {
} }
}; };
export const fetchGraphsStockBalanceHistory = async (productId: string): Promise<GraphsStockBalanceHistoryItem[]> => {
const response = await authFetch(`/stock/${encodeURIComponent(productId)}/graphs-balance-history`);
if (!response.ok) throw new Error('Não foi possível consultar o histórico de saldo Graphs.');
return response.json() as Promise<GraphsStockBalanceHistoryItem[]>;
};
export const saveGraphsStockBalance = async (productId: string, payload: GraphsStockBalancePayload): Promise<StockData> => {
const response = await authFetch(`/stock/${encodeURIComponent(productId)}/graphs-balance`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null) as StockData | { error?: string } | null;
if (!response.ok) throw new Error((data as { error?: string } | null)?.error || 'Não foi possível salvar o saldo Graphs.');
return data as StockData;
};
export const deleteGraphsStockBalance = async (productId: string): Promise<StockData> => {
const response = await authFetch(`/stock/${encodeURIComponent(productId)}/graphs-balance`, { method: 'DELETE' });
const data = await response.json().catch(() => null) as StockData | { error?: string } | null;
if (!response.ok) throw new Error((data as { error?: string } | null)?.error || 'Não foi possível remover o saldo Graphs.');
return data as StockData;
};
export const fetchData = async (): Promise<OrderData[]> => { export const fetchData = async (): Promise<OrderData[]> => {
try { try {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');

View File

@@ -1,16 +1,16 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, Download, Eye, PackageSearch, Pencil, Search } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Download, Eye, History, PackageSearch, Pencil, RotateCcw, Save, Search, X } from 'lucide-react';
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 RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import SkuPlanningModal from '../components/SkuPlanningModal'; import SkuPlanningModal from '../components/SkuPlanningModal';
import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting'; import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting';
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService'; import { deleteGraphsStockBalance, exportToCSV, fetchCuttingSettings, fetchGraphsStockBalanceHistory, fetchProductAnalytics, fetchStock, fetchSupplySummary, saveCuttingSettings, saveGraphsStockBalance } from '../dataService';
import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification'; import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
import { parseProductName } from '../productParsing'; import { parseProductName } from '../productParsing';
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplySummary } from '../types'; import type { CutProductOverride, CuttingSettings, DateRange, GraphsStockBalanceHistoryItem, ProductAnalyticsItem, StockData, SupplySummary } from '../types';
type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield'; type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield';
type IssueFilter = 'all' | IssueKey; type IssueFilter = 'all' | IssueKey;
@@ -140,6 +140,121 @@ const PlanningIssuesSkeleton = () => (
</div> </div>
); );
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);
};
const StockGraphsBalanceModal = ({
stock,
onClose,
onSaved
}: {
stock: StockData;
onClose: () => void;
onSaved: (stock: StockData) => void;
}) => {
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));
return <div className="fixed inset-0 z-50 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 ${stock.nome}`} 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={stock.nome}>{stock.nome}</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>;
};
const PlanningIssues = () => { const PlanningIssues = () => {
const { dateRange, setDateRange } = useOutletContext<{ const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange, dateRange: DateRange,
@@ -154,6 +269,7 @@ const PlanningIssues = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [issueFilter, setIssueFilter] = useState<IssueFilter>('all'); const [issueFilter, setIssueFilter] = useState<IssueFilter>('all');
const [editingProduct, setEditingProduct] = useState<PlanningIssueRow | null>(null); const [editingProduct, setEditingProduct] = useState<PlanningIssueRow | null>(null);
const [editingStock, setEditingStock] = useState<StockData | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
@@ -284,6 +400,13 @@ const PlanningIssues = () => {
} }
}; };
const saveGraphsBalance = (updatedStock: StockData) => {
setStockSnapshot(current => current.map(item => (
item.produto_id === updatedStock.produto_id ? updatedStock : item
)));
setEditingStock(updatedStock);
};
const exportActiveQueue = () => { const exportActiveQueue = () => {
const dateSuffix = new Date().toISOString().slice(0, 10); const dateSuffix = new Date().toISOString().slice(0, 10);
@@ -291,8 +414,9 @@ const PlanningIssues = () => {
exportToCSV(visibleNegativeStockRows.map(row => ({ exportToCSV(visibleNegativeStockRows.map(row => ({
sku: row.produto_id, sku: row.produto_id,
material: row.nome, material: row.nome,
saldo_informado: Number(row.saldo || 0), saldo_tiny: Number(row.saldo || 0),
planejamento: 'Usa 0 disponível' saldo_graphs: row.graphs_balance ?? '',
saldo_planejamento: Math.max(0, Number(row.planning_balance ?? row.saldo ?? 0))
})), `saldos-negativos-${dateSuffix}`); })), `saldos-negativos-${dateSuffix}`);
return; return;
} }
@@ -633,21 +757,23 @@ const PlanningIssues = () => {
visibleNegativeStockRows.length ? ( visibleNegativeStockRows.length ? (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"> <div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="flex flex-col gap-1 border-b border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-1 border-b border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div><h2 className="text-base font-bold text-dark-text">Saldos negativos</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Visíveis para auditoria; o planejamento considera saldo disponível igual a zero.</p></div> <div><h2 className="text-base font-bold text-dark-text">Saldos negativos</h2><p className="mt-1 text-sm font-semibold text-dark-muted">O saldo Tiny continua visível para auditoria. Informe uma conferência no Graphs para substituir sua base no planejamento.</p></div>
<span className="w-fit rounded-full border border-red-400/25 bg-red-400/10 px-2.5 py-1 text-xs font-bold text-red-300">{formatNumber(visibleNegativeStockRows.length)} materiais</span> <span className="w-fit rounded-full border border-red-400/25 bg-red-400/10 px-2.5 py-1 text-xs font-bold text-red-300">{formatNumber(visibleNegativeStockRows.length)} materiais</span>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[680px] table-fixed text-left text-sm"> <table className="w-full min-w-[920px] table-fixed text-left text-sm">
<colgroup> <colgroup>
<col className="w-[57%]" /> <col className="w-[44%]" />
<col className="w-[15%]" />
<col className="w-[15%]" />
<col className="w-[18%]" /> <col className="w-[18%]" />
<col className="w-[17%]" />
<col className="w-[8%]" /> <col className="w-[8%]" />
</colgroup> </colgroup>
<thead className="border-b border-dark-border bg-dark-header text-dark-muted"> <thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr> <tr>
<th className="px-5 py-3 text-[10px] font-bold uppercase tracking-wider">Material</th> <th className="px-5 py-3 text-[10px] font-bold uppercase tracking-wider">Material</th>
<th className="px-5 py-3 pr-16 text-right text-[10px] font-bold uppercase tracking-wider">Saldo informado</th> <th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Saldo Tiny</th>
<th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Saldo Graphs</th>
<th className="px-5 py-3 text-center text-[10px] font-bold uppercase tracking-wider">Planejamento</th> <th className="px-5 py-3 text-center text-[10px] font-bold uppercase tracking-wider">Planejamento</th>
<th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th> <th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr> </tr>
@@ -656,10 +782,20 @@ const PlanningIssues = () => {
{paginatedNegativeStockRows.map(row => ( {paginatedNegativeStockRows.map(row => (
<tr key={row.produto_id} className="transition-colors hover:bg-dark-input/50"> <tr key={row.produto_id} className="transition-colors hover:bg-dark-input/50">
<td className="max-w-0 px-5 py-3"><p className="truncate font-bold text-dark-text" title={row.nome}>{row.nome}</p><p className="mt-1 font-mono text-[11px] font-semibold text-dark-muted">#{row.produto_id}</p></td> <td className="max-w-0 px-5 py-3"><p className="truncate font-bold text-dark-text" title={row.nome}>{row.nome}</p><p className="mt-1 font-mono text-[11px] font-semibold text-dark-muted">#{row.produto_id}</p></td>
<td className="px-5 py-3 pr-16 text-right"><p className="font-bold text-red-300">{formatNumber(Number(row.saldo), 2)} un.</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">informado no Tiny</p></td> <td className="px-5 py-3 text-right"><p className="font-bold text-red-300">{formatNumber(Number(row.saldo), 2)} un.</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">importado do Tiny</p></td>
<td className="px-5 py-3 text-center"><span className="inline-flex rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">Usa 0 disponível</span></td>
<td className="px-5 py-3 text-right"> <td className="px-5 py-3 text-right">
<Link to="/supplies/inventory?tab=stock" title={`Conferir ${row.nome} no estoque`} aria-label={`Conferir ${row.nome} no estoque`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><Eye className="h-3.5 w-3.5" /></Link> {row.graphs_balance === null || row.graphs_balance === undefined ? (
<p className="text-xs font-bold text-dark-muted">Não informado</p>
) : (
<><p className="font-bold text-brand-primary">{formatNumber(Number(row.graphs_balance), 2)} un.</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">conferido no Graphs</p></>
)}
</td>
<td className="px-5 py-3 text-center"><span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-bold ${Number(row.planning_balance ?? row.saldo) > 0 ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-300' : 'border-dark-border bg-dark-input text-dark-muted'}`}>Usa {formatNumber(Math.max(0, Number(row.planning_balance ?? row.saldo)), 2)} disponível</span></td>
<td className="px-5 py-3 text-right">
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setEditingStock(row)} title={`Conferir saldo de ${row.nome}`} aria-label={`Conferir saldo de ${row.nome}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-text transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"><Pencil className="h-3.5 w-3.5" /></button>
<Link to="/supplies/inventory?tab=stock" title={`Ver ${row.nome} no estoque`} aria-label={`Ver ${row.nome} no estoque`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><Eye className="h-3.5 w-3.5" /></Link>
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -753,6 +889,13 @@ const PlanningIssues = () => {
onSave={(override) => saveProductOverride(editingProduct.id, override)} onSave={(override) => saveProductOverride(editingProduct.id, override)}
/> />
)} )}
{editingStock && (
<StockGraphsBalanceModal
stock={editingStock}
onClose={() => setEditingStock(null)}
onSaved={saveGraphsBalance}
/>
)}
</div> </div>
); );
}; };

View File

@@ -25,8 +25,32 @@ export interface StockData {
saldo: number; saldo: number;
delta_estoque: number; delta_estoque: number;
updated_at?: string; updated_at?: string;
graphs_balance?: number | null;
graphs_balance_reason?: string | null;
graphs_balance_effective_at?: string | null;
graphs_balance_updated_by?: string | null;
graphs_balance_updated_at?: string | null;
planning_balance?: number;
} }
export type GraphsStockBalancePayload = {
balance: number;
reason: string;
effectiveAt?: string | null;
};
export type GraphsStockBalanceHistoryItem = {
id: number;
produto_id: string;
tiny_balance: number | null;
graphs_balance: number | null;
reason: string | null;
effective_at: string | null;
action: 'set' | 'removed' | string;
changed_by: string | null;
created_at: string;
};
export type ProductionOrderStatus = 'open' | 'in_progress' | 'finished' | 'canceled' | string; export type ProductionOrderStatus = 'open' | 'in_progress' | 'finished' | 'canceled' | string;
export interface ProductionOrderMarker { export interface ProductionOrderMarker {