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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user