Add audited Graphs stock balances
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
This commit is contained in:
@@ -97,6 +97,35 @@ const initDB = async () => {
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).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(`
|
||||
CREATE TABLE IF NOT EXISTS stock_campaign_queue (
|
||||
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_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_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);`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { listStock, upsertStockItems } = require('../services/stockService');
|
||||
const { listGraphsBalanceHistory, listStock, removeGraphsBalance, saveGraphsBalance, upsertStockItems } = require('../services/stockService');
|
||||
|
||||
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) => {
|
||||
res.status(201).json({ message: 'Stock data received, processing in background' });
|
||||
|
||||
|
||||
@@ -5,10 +5,180 @@ const { enqueueStockCampaignItem } = require('./campaignService');
|
||||
const POSITIVE_STOCK_DELTA_THRESHOLD = 1;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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 client = await pool.connect();
|
||||
|
||||
@@ -52,5 +222,8 @@ const upsertStockItems = async (payload) => {
|
||||
|
||||
module.exports = {
|
||||
listStock,
|
||||
listGraphsBalanceHistory,
|
||||
saveGraphsBalance,
|
||||
removeGraphsBalance,
|
||||
upsertStockItems
|
||||
};
|
||||
|
||||
@@ -285,9 +285,10 @@ const listProjectDemandRows = async () => {
|
||||
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(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
|
||||
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, '') <> ''
|
||||
ORDER BY quantity_sold DESC, product_name;
|
||||
`, [DEFAULT_SUPPLY_LOOKBACK_DAYS]);
|
||||
@@ -356,9 +357,15 @@ const listCompositionRows = async () => {
|
||||
|
||||
const listTinyStockRows = async () => {
|
||||
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
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
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[]> => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
@@ -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 { 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 PaginationControls from '../components/PaginationControls';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||
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 { 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 IssueFilter = 'all' | IssueKey;
|
||||
@@ -140,6 +140,121 @@ const PlanningIssuesSkeleton = () => (
|
||||
</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 { dateRange, setDateRange } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
@@ -154,6 +269,7 @@ const PlanningIssues = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [issueFilter, setIssueFilter] = useState<IssueFilter>('all');
|
||||
const [editingProduct, setEditingProduct] = useState<PlanningIssueRow | null>(null);
|
||||
const [editingStock, setEditingStock] = useState<StockData | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
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 dateSuffix = new Date().toISOString().slice(0, 10);
|
||||
|
||||
@@ -291,8 +414,9 @@ const PlanningIssues = () => {
|
||||
exportToCSV(visibleNegativeStockRows.map(row => ({
|
||||
sku: row.produto_id,
|
||||
material: row.nome,
|
||||
saldo_informado: Number(row.saldo || 0),
|
||||
planejamento: 'Usa 0 disponível'
|
||||
saldo_tiny: Number(row.saldo || 0),
|
||||
saldo_graphs: row.graphs_balance ?? '',
|
||||
saldo_planejamento: Math.max(0, Number(row.planning_balance ?? row.saldo ?? 0))
|
||||
})), `saldos-negativos-${dateSuffix}`);
|
||||
return;
|
||||
}
|
||||
@@ -633,21 +757,23 @@ const PlanningIssues = () => {
|
||||
visibleNegativeStockRows.length ? (
|
||||
<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><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>
|
||||
</div>
|
||||
<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>
|
||||
<col className="w-[57%]" />
|
||||
<col className="w-[44%]" />
|
||||
<col className="w-[15%]" />
|
||||
<col className="w-[15%]" />
|
||||
<col className="w-[18%]" />
|
||||
<col className="w-[17%]" />
|
||||
<col className="w-[8%]" />
|
||||
</colgroup>
|
||||
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
||||
<tr>
|
||||
<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-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
@@ -656,10 +782,20 @@ const PlanningIssues = () => {
|
||||
{paginatedNegativeStockRows.map(row => (
|
||||
<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="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-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"><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-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>
|
||||
</tr>
|
||||
))}
|
||||
@@ -753,6 +889,13 @@ const PlanningIssues = () => {
|
||||
onSave={(override) => saveProductOverride(editingProduct.id, override)}
|
||||
/>
|
||||
)}
|
||||
{editingStock && (
|
||||
<StockGraphsBalanceModal
|
||||
stock={editingStock}
|
||||
onClose={() => setEditingStock(null)}
|
||||
onSaved={saveGraphsBalance}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
24
src/types.ts
24
src/types.ts
@@ -25,8 +25,32 @@ export interface StockData {
|
||||
saldo: number;
|
||||
delta_estoque: number;
|
||||
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 interface ProductionOrderMarker {
|
||||
|
||||
Reference in New Issue
Block a user