feat: apontamento direto no card com quantidade e botao todas, e filtro por perfil

This commit is contained in:
2026-09-22 21:08:13 +00:00
parent 816f406800
commit 69489579a9
2 changed files with 242 additions and 47 deletions
+53 -1
View File
@@ -19,6 +19,7 @@ interface ItemDisponivel {
id: string;
marca: string;
descricao: string;
perfil?: string;
tipo: 'peca' | 'componente';
quantidade_disponivel: number;
processo_atual_permitido: number;
@@ -161,7 +162,8 @@ export const ApontamentoForm = () => {
pecasDisponiveis.push({
id: peca.id,
marca: peca.marca,
descricao: peca.descricao || '',
descricao: peca.descricao || peca.perfil_principal || '',
perfil: peca.perfil_principal || peca.descricao || '',
tipo: 'peca',
quantidade_disponivel: saldoDisponivel,
processo_atual_permitido: ordemProcesso,
@@ -197,6 +199,7 @@ export const ApontamentoForm = () => {
id: comp.componente_ids[0] || '',
marca: comp.marca_componente,
descricao: comp.descricao || comp.perfil || '',
perfil: comp.perfil || comp.descricao || '',
tipo: 'componente',
quantidade_disponivel: saldoComp,
processo_atual_permitido: ordemProcesso,
@@ -364,6 +367,54 @@ export const ApontamentoForm = () => {
}));
};
const handleApontarItemDireto = async (item: ItemDisponivel, quantidade: number): Promise<boolean> => {
if (!formData.processo_id) {
toast.error('Selecione um processo antes de apontar');
return false;
}
if (isNaN(quantidade) || quantidade <= 0 || quantidade > item.quantidade_disponivel) {
toast.error(`Quantidade inválida. Saldo disponível: ${item.quantidade_disponivel}`);
return false;
}
setSaving(true);
try {
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || undefined
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
const result = await criarApontamento(apontamentoData);
if (result.success) {
toast.success(`${quantidade} un de ${item.marca} apontada(s) com sucesso!`);
await refetch();
resetFormForNewEntry();
return true;
} else {
toast.error('Erro ao registrar apontamento');
return false;
}
} catch (error) {
console.error('Erro no apontamento direto:', error);
toast.error('Erro ao registrar apontamento');
return false;
} finally {
setSaving(false);
}
};
const resetFormForNewEntry = () => {
setItemSelecionado(null);
setFormData(prev => ({
@@ -560,6 +611,7 @@ export const ApontamentoForm = () => {
itemSelecionado={itemSelecionado}
onItemSelect={handleItemSelect}
onBatchSelect={handleBatchSelect}
onApontarItemDireto={handleApontarItemDireto}
loading={isLoadingItens}
/>
</div>
@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from 'sonner';
import { Search, Package, Shield, AlertTriangle, Settings } from 'lucide-react';
import { Search, Package, Shield, AlertTriangle, Settings, Check, Loader2 } from 'lucide-react';
import { useDebounce } from '@/hooks/useDebounce';
import { Peca } from '@/hooks/usePecas';
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
@@ -19,10 +19,11 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
interface ItemDisponivel {
export interface ItemDisponivel {
id: string;
marca: string;
descricao: string;
perfil?: string;
tipo: 'peca' | 'componente';
quantidade_disponivel: number;
processo_atual_permitido: number;
@@ -54,6 +55,7 @@ interface SeletorItensOtimizadoProps {
itemSelecionado?: ItemDisponivel | null;
onItemSelect?: (item: ItemDisponivel) => void;
onBatchSelect?: (items: ItemDisponivel[], tipo: 'peca' | 'componente') => Promise<void>;
onApontarItemDireto?: (item: ItemDisponivel, quantidade: number) => Promise<boolean>;
loading?: boolean;
onSelectPeca?: (peca: PecaWithComponents) => void;
onSelectComponente?: (componente: ComponenteItemData) => void;
@@ -71,6 +73,7 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
itemSelecionado,
onItemSelect,
onBatchSelect,
onApontarItemDireto,
loading = false,
onSelectPeca,
onSelectComponente,
@@ -83,6 +86,11 @@ 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);
@@ -153,16 +161,81 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
const pecasOrdenadas = useMemo(() => {
let pecasFiltradas = pecasDisponiveis;
if (filtroNumeroPeca) {
const termo = filtroNumeroPeca.toLowerCase().trim();
pecasFiltradas = pecasDisponiveis.filter(peca =>
peca.marca.toLowerCase().includes(filtroNumeroPeca.toLowerCase())
peca.marca.toLowerCase().includes(termo) ||
(peca.perfil && peca.perfil.toLowerCase().includes(termo)) ||
(peca.descricao && peca.descricao.toLowerCase().includes(termo))
);
}
return pecasFiltradas.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }));
}, [pecasDisponiveis, filtroNumeroPeca]);
const componentesOrdenados = useMemo(() => {
return componentesDisponiveis.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }));
}, [componentesDisponiveis]);
let comps = componentesDisponiveis;
if (filtroNumeroPeca) {
const termo = filtroNumeroPeca.toLowerCase().trim();
comps = componentesDisponiveis.filter(comp =>
comp.marca.toLowerCase().includes(termo) ||
(comp.perfil && comp.perfil.toLowerCase().includes(termo)) ||
(comp.descricao && comp.descricao.toLowerCase().includes(termo))
);
}
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()) {
@@ -298,50 +371,120 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
</div>
);
const ItemDisponivelComponent = React.memo(({ item, isSelected }: { item: ItemDisponivel; isSelected: boolean }) => (
<div className="relative">
<div
className={`p-3 border rounded-md cursor-pointer transition-colors ${
isSelected
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border bg-card hover:bg-accent/50'
}`}
onClick={() => handleItemSelect(item)}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-foreground">{item.marca}</div>
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{item.quantidade_disponivel} disponível
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>
</div>
{item.descricao && (
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
{item.descricao}
{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 className="text-xs text-muted-foreground font-medium">
Processo: {item.nome_processo || item.processo_atual_permitido}
</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>
));
{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 (
@@ -418,10 +561,10 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<div className="flex items-center gap-2">
<Input
type="text"
placeholder="Filtrar por número da peça..."
placeholder="Filtrar por marca ou perfil..."
value={filtroNumeroPeca}
onChange={(e) => setFiltroNumeroPeca(e.target.value)}
className="bg-background border-input text-foreground placeholder:text-muted-foreground h-8 w-48 shadow-sm"
className="bg-background border-input text-foreground placeholder:text-muted-foreground h-8 w-56 shadow-sm text-xs"
/>
<Button
size="sm"