feat: apontamento direto no card com quantidade e botao todas, e filtro por perfil
This commit is contained in:
@@ -19,6 +19,7 @@ interface ItemDisponivel {
|
|||||||
id: string;
|
id: string;
|
||||||
marca: string;
|
marca: string;
|
||||||
descricao: string;
|
descricao: string;
|
||||||
|
perfil?: string;
|
||||||
tipo: 'peca' | 'componente';
|
tipo: 'peca' | 'componente';
|
||||||
quantidade_disponivel: number;
|
quantidade_disponivel: number;
|
||||||
processo_atual_permitido: number;
|
processo_atual_permitido: number;
|
||||||
@@ -161,7 +162,8 @@ export const ApontamentoForm = () => {
|
|||||||
pecasDisponiveis.push({
|
pecasDisponiveis.push({
|
||||||
id: peca.id,
|
id: peca.id,
|
||||||
marca: peca.marca,
|
marca: peca.marca,
|
||||||
descricao: peca.descricao || '',
|
descricao: peca.descricao || peca.perfil_principal || '',
|
||||||
|
perfil: peca.perfil_principal || peca.descricao || '',
|
||||||
tipo: 'peca',
|
tipo: 'peca',
|
||||||
quantidade_disponivel: saldoDisponivel,
|
quantidade_disponivel: saldoDisponivel,
|
||||||
processo_atual_permitido: ordemProcesso,
|
processo_atual_permitido: ordemProcesso,
|
||||||
@@ -197,6 +199,7 @@ export const ApontamentoForm = () => {
|
|||||||
id: comp.componente_ids[0] || '',
|
id: comp.componente_ids[0] || '',
|
||||||
marca: comp.marca_componente,
|
marca: comp.marca_componente,
|
||||||
descricao: comp.descricao || comp.perfil || '',
|
descricao: comp.descricao || comp.perfil || '',
|
||||||
|
perfil: comp.perfil || comp.descricao || '',
|
||||||
tipo: 'componente',
|
tipo: 'componente',
|
||||||
quantidade_disponivel: saldoComp,
|
quantidade_disponivel: saldoComp,
|
||||||
processo_atual_permitido: ordemProcesso,
|
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 = () => {
|
const resetFormForNewEntry = () => {
|
||||||
setItemSelecionado(null);
|
setItemSelecionado(null);
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
@@ -560,6 +611,7 @@ export const ApontamentoForm = () => {
|
|||||||
itemSelecionado={itemSelecionado}
|
itemSelecionado={itemSelecionado}
|
||||||
onItemSelect={handleItemSelect}
|
onItemSelect={handleItemSelect}
|
||||||
onBatchSelect={handleBatchSelect}
|
onBatchSelect={handleBatchSelect}
|
||||||
|
onApontarItemDireto={handleApontarItemDireto}
|
||||||
loading={isLoadingItens}
|
loading={isLoadingItens}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { toast } from 'sonner';
|
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 { useDebounce } from '@/hooks/useDebounce';
|
||||||
import { Peca } from '@/hooks/usePecas';
|
import { Peca } from '@/hooks/usePecas';
|
||||||
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
|
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
|
||||||
@@ -19,10 +19,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
interface ItemDisponivel {
|
export interface ItemDisponivel {
|
||||||
id: string;
|
id: string;
|
||||||
marca: string;
|
marca: string;
|
||||||
descricao: string;
|
descricao: string;
|
||||||
|
perfil?: string;
|
||||||
tipo: 'peca' | 'componente';
|
tipo: 'peca' | 'componente';
|
||||||
quantidade_disponivel: number;
|
quantidade_disponivel: number;
|
||||||
processo_atual_permitido: number;
|
processo_atual_permitido: number;
|
||||||
@@ -54,6 +55,7 @@ interface SeletorItensOtimizadoProps {
|
|||||||
itemSelecionado?: ItemDisponivel | null;
|
itemSelecionado?: ItemDisponivel | null;
|
||||||
onItemSelect?: (item: ItemDisponivel) => void;
|
onItemSelect?: (item: ItemDisponivel) => void;
|
||||||
onBatchSelect?: (items: ItemDisponivel[], tipo: 'peca' | 'componente') => Promise<void>;
|
onBatchSelect?: (items: ItemDisponivel[], tipo: 'peca' | 'componente') => Promise<void>;
|
||||||
|
onApontarItemDireto?: (item: ItemDisponivel, quantidade: number) => Promise<boolean>;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onSelectPeca?: (peca: PecaWithComponents) => void;
|
onSelectPeca?: (peca: PecaWithComponents) => void;
|
||||||
onSelectComponente?: (componente: ComponenteItemData) => void;
|
onSelectComponente?: (componente: ComponenteItemData) => void;
|
||||||
@@ -71,6 +73,7 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
|
|||||||
itemSelecionado,
|
itemSelecionado,
|
||||||
onItemSelect,
|
onItemSelect,
|
||||||
onBatchSelect,
|
onBatchSelect,
|
||||||
|
onApontarItemDireto,
|
||||||
loading = false,
|
loading = false,
|
||||||
onSelectPeca,
|
onSelectPeca,
|
||||||
onSelectComponente,
|
onSelectComponente,
|
||||||
@@ -83,6 +86,11 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
|
|||||||
const [loadingPecas, setLoadingPecas] = useState(false);
|
const [loadingPecas, setLoadingPecas] = useState(false);
|
||||||
const [loadingComponentes, setLoadingComponentes] = useState(false);
|
const [loadingComponentes, setLoadingComponentes] = useState(false);
|
||||||
const [componentes, setComponentes] = useState<ComponenteItemData[]>([]);
|
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 [showComponentes, setShowComponentes] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -153,16 +161,81 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
|
|||||||
const pecasOrdenadas = useMemo(() => {
|
const pecasOrdenadas = useMemo(() => {
|
||||||
let pecasFiltradas = pecasDisponiveis;
|
let pecasFiltradas = pecasDisponiveis;
|
||||||
if (filtroNumeroPeca) {
|
if (filtroNumeroPeca) {
|
||||||
|
const termo = filtroNumeroPeca.toLowerCase().trim();
|
||||||
pecasFiltradas = pecasDisponiveis.filter(peca =>
|
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' }));
|
return pecasFiltradas.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }));
|
||||||
}, [pecasDisponiveis, filtroNumeroPeca]);
|
}, [pecasDisponiveis, filtroNumeroPeca]);
|
||||||
|
|
||||||
const componentesOrdenados = useMemo(() => {
|
const componentesOrdenados = useMemo(() => {
|
||||||
return componentesDisponiveis.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }));
|
let comps = componentesDisponiveis;
|
||||||
}, [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) => {
|
const fetchPecas = useCallback(async (term: string) => {
|
||||||
if (!term.trim()) {
|
if (!term.trim()) {
|
||||||
@@ -298,50 +371,120 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ItemDisponivelComponent = React.memo(({ item, isSelected }: { item: ItemDisponivel; isSelected: boolean }) => (
|
const ItemDisponivelComponent = ({ item, isSelected }: { item: ItemDisponivel; isSelected: boolean }) => {
|
||||||
<div className="relative">
|
const isTodas = Boolean(todasCards[item.id]);
|
||||||
<div
|
const qtdValue = qtdCards[item.id] ?? (isTodas ? item.quantidade_disponivel.toString() : '');
|
||||||
className={`p-3 border rounded-md cursor-pointer transition-colors ${
|
const isLoading = loadingApontandoId === item.id;
|
||||||
isSelected
|
|
||||||
? 'border-primary bg-primary/10 ring-1 ring-primary'
|
return (
|
||||||
: 'border-border bg-card hover:bg-accent/50'
|
<div className="relative">
|
||||||
}`}
|
<div
|
||||||
onClick={() => handleItemSelect(item)}
|
className={`p-3 border rounded-md transition-colors ${
|
||||||
>
|
isSelected
|
||||||
<div className="space-y-1.5">
|
? 'border-primary bg-primary/10 ring-1 ring-primary'
|
||||||
<div className="flex items-center justify-between">
|
: 'border-border bg-card hover:bg-accent/40'
|
||||||
<div className="font-semibold text-sm text-foreground">{item.marca}</div>
|
}`}
|
||||||
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
|
onClick={() => handleItemSelect(item)}
|
||||||
{item.quantidade_disponivel} disponível
|
>
|
||||||
|
<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>
|
||||||
</div>
|
|
||||||
{item.descricao && (
|
{item.descricao && item.descricao !== item.perfil && (
|
||||||
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
|
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
|
||||||
{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 className="text-xs text-muted-foreground font-medium">
|
|
||||||
Processo: {item.nome_processo || item.processo_atual_permitido}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
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"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleAdminAction(item);
|
handleAdminAction(item);
|
||||||
}}
|
}}
|
||||||
title="Verificar apontamentos e forçar exclusão"
|
title="Verificar apontamentos e forçar exclusão"
|
||||||
>
|
>
|
||||||
<Settings className="h-3 w-3" />
|
<Settings className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
));
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (onSelectPeca) {
|
if (onSelectPeca) {
|
||||||
return (
|
return (
|
||||||
@@ -418,10 +561,10 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filtrar por número da peça..."
|
placeholder="Filtrar por marca ou perfil..."
|
||||||
value={filtroNumeroPeca}
|
value={filtroNumeroPeca}
|
||||||
onChange={(e) => setFiltroNumeroPeca(e.target.value)}
|
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user