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

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

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

View File

@@ -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 {