fix(apontamento): isolar estado de quantidade no card para evitar perda de foco ao digitar

This commit is contained in:
2026-10-21 15:30:01 -03:00
parent d28e131d7a
commit ada8e4c460
@@ -67,6 +67,257 @@ interface PecaWithComponents extends Peca {
processo_atual_permitido?: number;
}
const getPrioridadeColor = (prioridade?: string) => {
switch (prioridade?.toLowerCase()) {
case 'alta': return 'text-red-600 dark:text-red-400';
case 'média': return 'text-amber-600 dark:text-yellow-400';
case 'baixa': return 'text-emerald-600 dark:text-green-400';
default: return 'text-muted-foreground';
}
};
const PecaItem = React.memo(({ peca, onSelect }: { peca: PecaWithComponents; onSelect: (peca: PecaWithComponents) => void }) => {
return (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(peca)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{peca.marca}</div>
<div className="text-xs text-muted-foreground">
Prioridade: <span className={getPrioridadeColor(peca.prioridade)}>{peca.prioridade || 'N/A'}</span>
</div>
{peca.descricao && (
<div className="text-xs text-muted-foreground truncate" title={peca.descricao}>
{peca.descricao}
</div>
)}
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
Qtd Disponível: {peca.quantidadeDisponivel || 0}
</div>
</div>
</div>
);
});
PecaItem.displayName = 'PecaItem';
const ComponenteItem = React.memo(({ componente, onSelect }: { componente: ComponenteItemData; onSelect: (componente: ComponenteItemData) => void }) => (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(componente)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{componente.marca_componente}</div>
<div className="text-xs text-muted-foreground">
Perfil: {componente.perfil || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
Peso Unitário: {componente.peso_unitario || 0}kg | Qtd por Peça: {componente.quantidade_por_peca || 1}
</div>
{componente.descricao && (
<div className="text-xs text-muted-foreground truncate" title={componente.descricao}>
{componente.descricao}
</div>
)}
</div>
</div>
));
ComponenteItem.displayName = 'ComponenteItem';
interface ItemDisponivelCardProps {
item: ItemDisponivel;
isSelected: boolean;
isAdmin?: boolean;
onSelect?: (item: ItemDisponivel) => void;
onAdminAction?: (item: ItemDisponivel) => void;
onApontarItemDireto?: (item: ItemDisponivel, quantidade: number) => Promise<boolean>;
}
const ItemDisponivelCard = React.memo(({
item,
isSelected,
isAdmin,
onSelect,
onAdminAction,
onApontarItemDireto,
}: ItemDisponivelCardProps) => {
const [qtdValue, setQtdValue] = useState<string>('');
const [isTodas, setIsTodas] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
// Mantém sincronizado se a quantidade disponível mudar ou modo 'todas' estiver ativo
useEffect(() => {
if (isTodas) {
setQtdValue(item.quantidade_disponivel.toString());
} else if (qtdValue) {
const parsed = parseInt(qtdValue, 10);
if (!isNaN(parsed) && parsed > item.quantidade_disponivel) {
setQtdValue(item.quantidade_disponivel.toString());
}
}
}, [item.quantidade_disponivel, isTodas]);
const handleToggleTodas = () => {
setIsTodas((prev) => {
const novo = !prev;
setQtdValue(novo ? item.quantidade_disponivel.toString() : '');
return novo;
});
};
const handleQtdChange = (val: string) => {
setQtdValue(val);
if (isTodas) {
setIsTodas(false);
}
};
const handleExecutarBaixa = async () => {
if (!onApontarItemDireto || isLoading) return;
const quantidade = isTodas ? item.quantidade_disponivel : parseInt(qtdValue, 10);
if (isNaN(quantidade) || quantidade <= 0) {
toast.error(`Informe a quantidade para ${item.marca} ou ative 'Todas'`);
return;
}
if (quantidade > item.quantidade_disponivel) {
toast.error(`Quantidade não pode ser maior que ${item.quantidade_disponivel} disponível`);
return;
}
setIsLoading(true);
try {
const sucesso = await onApontarItemDireto(item, quantidade);
if (sucesso) {
setQtdValue('');
setIsTodas(false);
}
} catch (error) {
console.error('Erro ao executar baixa direta:', error);
toast.error('Erro ao registrar apontamento');
} finally {
setIsLoading(false);
}
};
return (
<div className="relative">
<div
className={`p-3 border rounded-md transition-colors ${
isSelected
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border bg-card hover:bg-accent/40'
}`}
onClick={() => onSelect?.(item)}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-foreground flex items-center gap-2">
<span>{item.marca}</span>
{item.perfil && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono font-normal">
{item.perfil}
</span>
)}
</div>
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{item.quantidade_disponivel} disponível
</div>
</div>
{item.descricao && item.descricao !== item.perfil && (
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
{item.descricao}
</div>
)}
{/* Linha com processo e controles rápidos de apontamento */}
<div className="flex items-center justify-between gap-2 pt-1 flex-wrap">
<div className="text-xs text-muted-foreground font-medium">
Processo: {item.nome_processo || item.processo_atual_permitido}
</div>
{onApontarItemDireto && (
<div
className="flex items-center gap-1.5 ml-auto"
onClick={(e) => e.stopPropagation()}
>
<Input
type="number"
min="1"
max={item.quantidade_disponivel}
placeholder="Quant."
value={qtdValue}
onChange={(e) => handleQtdChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleExecutarBaixa();
}
}}
disabled={isTodas || isLoading}
className="h-7 w-20 text-xs px-2 text-center font-medium bg-background border-input shadow-none"
/>
<Button
type="button"
size="sm"
variant={isTodas ? "default" : "outline"}
onClick={handleToggleTodas}
disabled={isLoading}
className={`h-7 px-2.5 text-xs font-semibold transition-colors ${
isTodas
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600 shadow-sm"
: "hover:bg-accent text-foreground"
}`}
title="Habilitar/desabilitar apontar todas as unidades disponíveis"
>
Todas
</Button>
<Button
type="button"
size="sm"
disabled={isLoading || (!isTodas && (!qtdValue || parseInt(qtdValue, 10) <= 0))}
onClick={handleExecutarBaixa}
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs flex items-center gap-1 shadow-sm transition-all"
title="Baixar quantidade no apontamento"
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Check className="h-3.5 w-3.5" />
<span>Baixar</span>
</>
)}
</Button>
</div>
)}
</div>
</div>
</div>
{isAdmin && onAdminAction && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
onAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<Settings className="h-3 w-3" />
</Button>
)}
</div>
);
});
ItemDisponivelCard.displayName = 'ItemDisponivelCard';
export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
pecasDisponiveis = [],
componentesDisponiveis = [],
@@ -86,11 +337,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
const [loadingPecas, setLoadingPecas] = useState(false);
const [loadingComponentes, setLoadingComponentes] = useState(false);
const [componentes, setComponentes] = useState<ComponenteItemData[]>([]);
// Estados locais para apontamento direto por card (quantidade, modo todas e loading)
const [qtdCards, setQtdCards] = useState<Record<string, string>>({});
const [todasCards, setTodasCards] = useState<Record<string, boolean>>({});
const [loadingApontandoId, setLoadingApontandoId] = useState<string | null>(null);
const [showComponentes, setShowComponentes] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -184,59 +430,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
return comps.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }));
}, [componentesDisponiveis, filtroNumeroPeca]);
const handleQtdCardChange = useCallback((itemId: string, value: string) => {
setQtdCards(prev => ({ ...prev, [itemId]: value }));
setTodasCards(prev => ({ ...prev, [itemId]: false }));
}, []);
const handleToggleTodas = useCallback((itemId: string, qtdDisponivel: number) => {
setTodasCards(prev => {
const novoEstado = !prev[itemId];
if (novoEstado) {
setQtdCards(q => ({ ...q, [itemId]: qtdDisponivel.toString() }));
} else {
setQtdCards(q => ({ ...q, [itemId]: '' }));
}
return { ...prev, [itemId]: novoEstado };
});
}, []);
const handleExecutarBaixa = useCallback(async (item: ItemDisponivel) => {
if (!onApontarItemDireto) return;
const isTodas = todasCards[item.id];
const qtdDigitada = qtdCards[item.id];
const quantidade = isTodas ? item.quantidade_disponivel : parseInt(qtdDigitada);
if (isNaN(quantidade) || quantidade <= 0) {
toast.error(`Informe a quantidade para ${item.marca} ou ative 'Todas'`);
return;
}
if (quantidade > item.quantidade_disponivel) {
toast.error(`Quantidade não pode ser maior que ${item.quantidade_disponivel} disponível`);
return;
}
setLoadingApontandoId(item.id);
try {
const sucesso = await onApontarItemDireto(item, quantidade);
if (sucesso) {
setQtdCards(prev => {
const copia = { ...prev };
delete copia[item.id];
return copia;
});
setTodasCards(prev => {
const copia = { ...prev };
delete copia[item.id];
return copia;
});
}
} finally {
setLoadingApontandoId(null);
}
}, [onApontarItemDireto, todasCards, qtdCards]);
const fetchPecas = useCallback(async (term: string) => {
if (!term.trim()) {
setPecasFiltradas([]);
@@ -318,174 +511,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
}
}, [onBatchSelect, pecasOrdenadas, componentesOrdenados]);
const getPrioridadeColor = (prioridade: string) => {
switch (prioridade?.toLowerCase()) {
case 'alta': return 'text-red-600 dark:text-red-400';
case 'média': return 'text-amber-600 dark:text-yellow-400';
case 'baixa': return 'text-emerald-600 dark:text-green-400';
default: return 'text-muted-foreground';
}
};
const PecaItem = ({ peca, onSelect }: { peca: PecaWithComponents; onSelect: (peca: PecaWithComponents) => void }) => {
return (
<div className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(peca)}>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{peca.marca}</div>
<div className="text-xs text-muted-foreground">
Prioridade: <span className={getPrioridadeColor(peca.prioridade)}>{peca.prioridade || 'N/A'}</span>
</div>
{peca.descricao && (
<div className="text-xs text-muted-foreground truncate" title={peca.descricao}>
{peca.descricao}
</div>
)}
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
Qtd Disponível: {peca.quantidadeDisponivel || 0}
</div>
</div>
</div>
);
};
const ComponenteItem = ({ componente }: { componente: ComponenteItemData }) => (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => handleComponenteSelect(componente)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{componente.marca_componente}</div>
<div className="text-xs text-muted-foreground">
Perfil: {componente.perfil || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
Peso Unitário: {componente.peso_unitario || 0}kg | Qtd por Peça: {componente.quantidade_por_peca || 1}
</div>
{componente.descricao && (
<div className="text-xs text-muted-foreground truncate" title={componente.descricao}>
{componente.descricao}
</div>
)}
</div>
</div>
);
const ItemDisponivelComponent = ({ item, isSelected }: { item: ItemDisponivel; isSelected: boolean }) => {
const isTodas = Boolean(todasCards[item.id]);
const qtdValue = qtdCards[item.id] ?? (isTodas ? item.quantidade_disponivel.toString() : '');
const isLoading = loadingApontandoId === item.id;
return (
<div className="relative">
<div
className={`p-3 border rounded-md transition-colors ${
isSelected
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border bg-card hover:bg-accent/40'
}`}
onClick={() => handleItemSelect(item)}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-foreground flex items-center gap-2">
<span>{item.marca}</span>
{item.perfil && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono font-normal">
{item.perfil}
</span>
)}
</div>
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{item.quantidade_disponivel} disponível
</div>
</div>
{item.descricao && item.descricao !== item.perfil && (
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
{item.descricao}
</div>
)}
{/* Linha com processo e controles rápidos de apontamento desenhados pelo usuário */}
<div className="flex items-center justify-between gap-2 pt-1 flex-wrap">
<div className="text-xs text-muted-foreground font-medium">
Processo: {item.nome_processo || item.processo_atual_permitido}
</div>
{onApontarItemDireto && (
<div
className="flex items-center gap-1.5 ml-auto"
onClick={(e) => e.stopPropagation()}
>
<Input
type="number"
min="1"
max={item.quantidade_disponivel}
placeholder="Quant."
value={qtdValue}
onChange={(e) => handleQtdCardChange(item.id, e.target.value)}
disabled={isTodas || isLoading}
className="h-7 w-20 text-xs px-2 text-center font-medium bg-background border-input shadow-none"
/>
<Button
type="button"
size="sm"
variant={isTodas ? "default" : "outline"}
onClick={() => handleToggleTodas(item.id, item.quantidade_disponivel)}
disabled={isLoading}
className={`h-7 px-2.5 text-xs font-semibold transition-colors ${
isTodas
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600 shadow-sm"
: "hover:bg-accent text-foreground"
}`}
title="Habilitar/desabilitar apontar todas as unidades disponíveis"
>
Todas
</Button>
<Button
type="button"
size="sm"
disabled={isLoading || (!isTodas && (!qtdValue || parseInt(qtdValue) <= 0))}
onClick={() => handleExecutarBaixa(item)}
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs flex items-center gap-1 shadow-sm transition-all"
title="Baixar quantidade no apontamento"
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Check className="h-3.5 w-3.5" />
<span>Baixar</span>
</>
)}
</Button>
</div>
)}
</div>
</div>
</div>
{isAdmin && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
handleAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<Settings className="h-3 w-3" />
</Button>
)}
</div>
);
};
if (onSelectPeca) {
return (
<div className="space-y-4">
@@ -527,7 +552,7 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[200px] p-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{componentes.map((componente) => (
<ComponenteItem key={componente.id} componente={componente} />
<ComponenteItem key={componente.id} componente={componente} onSelect={handleComponenteSelect} />
))}
{componentes.length === 0 && (
<div className="text-center text-muted-foreground col-span-full">
@@ -592,27 +617,15 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[300px] p-2">
<div className="space-y-2">
{pecasOrdenadas.map((item) => (
<div key={item.id} className="relative">
<ItemDisponivelComponent
item={item}
isSelected={itemSelecionado?.id === item.id}
/>
{isAdmin && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
handleAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<AlertTriangle className="h-3 w-3" />
</Button>
)}
</div>
<ItemDisponivelCard
key={item.id}
item={item}
isSelected={itemSelecionado?.id === item.id}
isAdmin={isAdmin}
onSelect={handleItemSelect}
onAdminAction={handleAdminAction}
onApontarItemDireto={onApontarItemDireto}
/>
))}
{pecasOrdenadas.length === 0 && (
<div className="text-center text-muted-foreground py-8">
@@ -645,10 +658,14 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[300px] p-2">
<div className="space-y-2">
{componentesOrdenados.map((item) => (
<ItemDisponivelComponent
<ItemDisponivelCard
key={item.id}
item={item}
isSelected={itemSelecionado?.id === item.id}
isAdmin={isAdmin}
onSelect={handleItemSelect}
onAdminAction={handleAdminAction}
onApontarItemDireto={onApontarItemDireto}
/>
))}
</div>