Compare commits
15 Commits
1203650c2d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ebc8dec5c | |||
| 6a8290f394 | |||
| 7f98ae9803 | |||
| 2bf0ccd8ce | |||
| ea447df298 | |||
| 1e191fe562 | |||
| ada8e4c460 | |||
| d28e131d7a | |||
| 8a707894e0 | |||
| 9ade5d0d5a | |||
| fcb3fc08fd | |||
| f3eff4b805 | |||
| 264c41cc80 | |||
| 9745964837 | |||
| 400be62a0e |
@@ -49,10 +49,10 @@ export const ApontamentoForm = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cacheValido, setCacheValido] = useState(false);
|
||||
|
||||
const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos } = useApontamentosProducao();
|
||||
const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos, sincronizarKitsOF } = useApontamentosProducao();
|
||||
const { pecas, loading: loadingPecas } = usePecas();
|
||||
const { ofs } = useOFs();
|
||||
const { componentesAgrupados, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
|
||||
const { componentesAgrupados, componentesRaw, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
|
||||
|
||||
// Buscar fases únicas da OF selecionada
|
||||
const fasesDisponiveis = useMemo(() => {
|
||||
@@ -118,6 +118,8 @@ export const ApontamentoForm = () => {
|
||||
const processosOrdenados = [...processos].sort((a, b) => a.ordem - b.ordem);
|
||||
const indexAtual = processosOrdenados.findIndex(p => p.id === processo_id);
|
||||
const procAtual = processosOrdenados[indexAtual];
|
||||
const procNomeLower = (procAtual?.nome || '').toLowerCase();
|
||||
const isCorte = procNomeLower.includes('corte') || procNomeLower.includes('prepar') || indexAtual === 0;
|
||||
|
||||
// 1. Peças da OF e Fase selecionadas
|
||||
const pecasDaFase = pecas.filter(
|
||||
@@ -134,25 +136,60 @@ export const ApontamentoForm = () => {
|
||||
|
||||
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
|
||||
|
||||
// Regra: peças sem componentes não passam por solda
|
||||
if (!peca.tem_componentes && procAtual && procAtual.nome.toLowerCase().includes('solda')) {
|
||||
// Identificar se a peça de fato possui subcomponentes cadastrados nesta OF
|
||||
const compsDaPeca = componentesAgrupados?.filter(c => c.peca_ids?.includes(peca.id)) || [];
|
||||
const temComponentesCadastrados = peca.tem_componentes !== false && compsDaPeca.length > 0;
|
||||
const ehSemMontagem = peca.tem_componentes === false;
|
||||
|
||||
// Se for CORTE:
|
||||
// Se tiver componentes cadastrados, são apontados os subcomponentes.
|
||||
// Se NÃO tiver componentes cadastrados (opção do usuário não controlar componentes), aponta a peça mestre normalmente!
|
||||
if (isCorte && temComponentesCadastrados) {
|
||||
qtdDisponivelParaEntrar = 0;
|
||||
} else if (ehSemMontagem && procNomeLower.includes('solda')) {
|
||||
// Regra: peças sem montagem (S/M) não passam por solda
|
||||
qtdDisponivelParaEntrar = 0;
|
||||
} else if (indexAtual > 0) {
|
||||
// Encontrar processo anterior válido
|
||||
let processoAnteriorValido = null;
|
||||
for (let i = indexAtual - 1; i >= 0; i--) {
|
||||
const p = processosOrdenados[i];
|
||||
if (!peca.tem_componentes && p.nome.toLowerCase().includes('solda')) {
|
||||
continue; // Pula a solda na busca do processo anterior para peças simples
|
||||
if (ehSemMontagem && p.nome.toLowerCase().includes('solda')) {
|
||||
continue; // Pula a solda na busca do processo anterior para peças simples (S/M)
|
||||
}
|
||||
processoAnteriorValido = p;
|
||||
break;
|
||||
}
|
||||
|
||||
if (processoAnteriorValido) {
|
||||
qtdDisponivelParaEntrar = apontamentos
|
||||
let qtdApontadaAnterior = apontamentos
|
||||
.filter(a => a.tipo_apontamento === 'peca' && a.peca_id === peca.id && a.processo_id === processoAnteriorValido.id)
|
||||
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||
|
||||
// Se a peça tem componentes e o processo anterior for Corte:
|
||||
// Calcula estritamente os kits completos disponíveis baseados no corte de todos os componentes (regra do gargalo)
|
||||
if (temComponentesCadastrados && processoAnteriorValido.nome.toLowerCase().includes('corte')) {
|
||||
const compsDestaPeca = (componentesRaw || []).filter(c => c.peca_id === peca.id);
|
||||
if (compsDestaPeca.length > 0) {
|
||||
let menorKits = Number(peca.quantidade) || 0;
|
||||
compsDestaPeca.forEach(comp => {
|
||||
const cortado = apontamentos
|
||||
.filter(a => a.tipo_apontamento === 'componente' && a.componente_id === comp.id && a.processo_id === processoAnteriorValido.id)
|
||||
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
|
||||
const kitsDeste = Math.floor(cortado / porPeca);
|
||||
if (kitsDeste < menorKits) {
|
||||
menorKits = kitsDeste;
|
||||
}
|
||||
});
|
||||
const kitsCompletosCalculados = Math.max(0, Math.min(Number(peca.quantidade) || 0, menorKits));
|
||||
if (kitsCompletosCalculados > qtdApontadaAnterior) {
|
||||
qtdApontadaAnterior = kitsCompletosCalculados;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qtdDisponivelParaEntrar = qtdApontadaAnterior;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,21 +210,14 @@ export const ApontamentoForm = () => {
|
||||
});
|
||||
|
||||
// 2. Componentes da OF e Fase selecionadas
|
||||
// RESTRIÇÃO INDUSTRIAL: Componentes só existem no processo de CORTE / PREPARAÇÃO!
|
||||
// Em Solda, Pintura, Expedição, Montagem etc., componentes não são exibidos nem apontados.
|
||||
const componentesDisponiveis: ItemDisponivel[] = [];
|
||||
|
||||
if (componentesAgrupados && componentesAgrupados.length > 0) {
|
||||
if (isCorte && componentesAgrupados && componentesAgrupados.length > 0) {
|
||||
componentesAgrupados.forEach(comp => {
|
||||
let qtdDisponivelParaEntrarComp = Number(comp.quantidade_total) || 0;
|
||||
|
||||
if (indexAtual > 0) {
|
||||
const processoAnteriorValido = processosOrdenados[indexAtual - 1];
|
||||
if (processoAnteriorValido) {
|
||||
qtdDisponivelParaEntrarComp = apontamentos
|
||||
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processoAnteriorValido.id)
|
||||
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
const totalApontadoComp = apontamentos
|
||||
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processo_id)
|
||||
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||
@@ -263,23 +293,12 @@ export const ApontamentoForm = () => {
|
||||
try {
|
||||
for (const item of items) {
|
||||
try {
|
||||
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: item.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: item.quantidade_disponivel,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: `Registro em lote - ${tipoTexto}`
|
||||
};
|
||||
const result = await registrarItemComDistribuicao(
|
||||
item,
|
||||
item.quantidade_disponivel,
|
||||
`Registro em lote - ${tipoTexto}`
|
||||
);
|
||||
|
||||
if (item.tipo === 'componente') {
|
||||
apontamentoData.componente_id = item.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = item.id;
|
||||
}
|
||||
|
||||
const result = await criarApontamento(apontamentoData);
|
||||
|
||||
if (result.success) {
|
||||
sucessos++;
|
||||
} else {
|
||||
@@ -341,6 +360,13 @@ export const ApontamentoForm = () => {
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ processo_id: processoId });
|
||||
setItemSelecionado(null);
|
||||
|
||||
// Ao selecionar Solda, acionar sincronização de kits da OF em segundo plano
|
||||
const proc = processos.find(p => p.id === processoId);
|
||||
const procNome = (proc?.nome || '').toLowerCase();
|
||||
if ((procNome.includes('solda') || procNome.includes('montag')) && formData.of_number) {
|
||||
sincronizarKitsOF(formData.of_number);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemSelect = (item: ItemDisponivel) => {
|
||||
@@ -367,6 +393,81 @@ export const ApontamentoForm = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
// Função auxiliar para registrar apontamento distribuindo componentes entre peças irmãs se necessário
|
||||
const registrarItemComDistribuicao = async (
|
||||
item: ItemDisponivel,
|
||||
quantidade: number,
|
||||
observacoes?: string
|
||||
): Promise<{ success: boolean }> => {
|
||||
if (item.tipo === 'componente') {
|
||||
const grupo = componentesAgrupados.find(g => g.marca_componente === item.marca);
|
||||
if (grupo && grupo.vinculos && grupo.vinculos.length > 1) {
|
||||
let restante = quantidade;
|
||||
let sucessos = 0;
|
||||
|
||||
for (const v of grupo.vinculos) {
|
||||
if (restante <= 0) break;
|
||||
|
||||
const jaCortado = apontamentos
|
||||
.filter(a => a.tipo_apontamento === 'componente' && a.componente_id === v.componente_id && a.processo_id === formData.processo_id)
|
||||
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
|
||||
|
||||
const faltaParaPeca = Math.max(0, v.quantidade_necessaria - jaCortado);
|
||||
if (faltaParaPeca <= 0) continue;
|
||||
|
||||
const qtdParaV = Math.min(restante, faltaParaPeca);
|
||||
restante -= qtdParaV;
|
||||
|
||||
const res = await criarApontamento({
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: 'componente',
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: qtdParaV,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
componente_id: v.componente_id,
|
||||
observacoes: observacoes || undefined
|
||||
});
|
||||
|
||||
if (res.success) sucessos++;
|
||||
}
|
||||
|
||||
// Se sobrou saldo além das necessidades das peças vinculadas, aplica ao primeiro vínculo
|
||||
if (restante > 0 && grupo.vinculos.length > 0) {
|
||||
const res = await criarApontamento({
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: 'componente',
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: restante,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
componente_id: grupo.vinculos[0].componente_id,
|
||||
observacoes: observacoes || undefined
|
||||
});
|
||||
if (res.success) sucessos++;
|
||||
}
|
||||
|
||||
return { success: sucessos > 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Caso padrão (peça ou componente monovinculado)
|
||||
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: observacoes || undefined
|
||||
};
|
||||
|
||||
if (item.tipo === 'componente') {
|
||||
apontamentoData.componente_id = item.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = item.id;
|
||||
}
|
||||
|
||||
return await criarApontamento(apontamentoData);
|
||||
};
|
||||
|
||||
const handleApontarItemDireto = async (item: ItemDisponivel, quantidade: number): Promise<boolean> => {
|
||||
if (!formData.processo_id) {
|
||||
toast.error('Selecione um processo antes de apontar');
|
||||
@@ -380,22 +481,7 @@ export const ApontamentoForm = () => {
|
||||
|
||||
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);
|
||||
const result = await registrarItemComDistribuicao(item, quantidade, formData.observacoes || undefined);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${quantidade} un de ${item.marca} apontada(s) com sucesso!`);
|
||||
@@ -443,22 +529,11 @@ export const ApontamentoForm = () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: itemSelecionado.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: quantidade,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: formData.observacoes || undefined
|
||||
};
|
||||
|
||||
if (itemSelecionado.tipo === 'componente') {
|
||||
apontamentoData.componente_id = itemSelecionado.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = itemSelecionado.id;
|
||||
}
|
||||
|
||||
const result = await criarApontamento(apontamentoData);
|
||||
const result = await registrarItemComDistribuicao(
|
||||
itemSelecionado,
|
||||
quantidade,
|
||||
formData.observacoes || undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Apontamento registrado com sucesso!');
|
||||
|
||||
@@ -12,12 +12,15 @@ import { ptBR } from 'date-fns/locale';
|
||||
export const ApontamentosList = () => {
|
||||
const { apontamentos, loading } = useApontamentosProducao();
|
||||
const [filtroOF, setFiltroOF] = useState('');
|
||||
const [filtroFase, setFiltroFase] = useState('');
|
||||
const [filtroProcesso, setFiltroProcesso] = useState('');
|
||||
const [filtroData, setFiltroData] = useState('');
|
||||
const [filtroPecaComponente, setFiltroPecaComponente] = useState('');
|
||||
|
||||
const apontamentosFiltrados = apontamentos.filter(apt => {
|
||||
const matchOF = !filtroOF || apt.of_number.toLowerCase().includes(filtroOF.toLowerCase());
|
||||
const faseStr = apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase || '';
|
||||
const matchFase = !filtroFase || faseStr.toLowerCase().includes(filtroFase.toLowerCase());
|
||||
const matchProcesso = !filtroProcesso || apt.processo?.nome.toLowerCase().includes(filtroProcesso.toLowerCase());
|
||||
const matchData = !filtroData || apt.data_apontamento.includes(filtroData);
|
||||
|
||||
@@ -25,7 +28,7 @@ export const ApontamentosList = () => {
|
||||
const matchPecaComponente = !filtroPecaComponente ||
|
||||
getMarcaExibida(apt).toLowerCase().includes(filtroPecaComponente.toLowerCase());
|
||||
|
||||
return matchOF && matchProcesso && matchData && matchPecaComponente;
|
||||
return matchOF && matchFase && matchProcesso && matchData && matchPecaComponente;
|
||||
});
|
||||
|
||||
// Função para determinar a marca exibida baseada no tipo de apontamento
|
||||
@@ -90,7 +93,7 @@ export const ApontamentosList = () => {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">OF</label>
|
||||
<Input
|
||||
@@ -100,6 +103,15 @@ export const ApontamentosList = () => {
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Fase</label>
|
||||
<Input
|
||||
placeholder="Filtrar por fase..."
|
||||
value={filtroFase}
|
||||
onChange={(e) => setFiltroFase(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Processo</label>
|
||||
<Input
|
||||
@@ -163,6 +175,7 @@ export const ApontamentosList = () => {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>OF</TableHead>
|
||||
<TableHead>Fase</TableHead>
|
||||
<TableHead>Peça/Componente</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Processo</TableHead>
|
||||
@@ -184,6 +197,17 @@ export const ApontamentosList = () => {
|
||||
<TableCell className="font-medium py-1">
|
||||
{apontamento.of_number}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
{apontamento.peca?.etapa_fase ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{apontamento.peca.etapa_fase.toLowerCase().startsWith('fase') || apontamento.peca.etapa_fase.toLowerCase().startsWith('etapa')
|
||||
? apontamento.peca.etapa_fase
|
||||
: `Fase ${apontamento.peca.etapa_fase}`}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="font-medium">
|
||||
{getMarcaExibida(apontamento)}
|
||||
|
||||
@@ -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, Check, Loader2 } from 'lucide-react';
|
||||
import { Search, Package, Shield, AlertTriangle, Settings, Check, Loader2, Layers } from 'lucide-react';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { Peca } from '@/hooks/usePecas';
|
||||
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
|
||||
@@ -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">
|
||||
@@ -628,7 +641,10 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
|
||||
{componentesDisponiveis.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">Componentes Disponíveis</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Layers className="h-5 w-5 text-blue-500" />
|
||||
Componentes para Corte (Controle de Kit)
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -642,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>
|
||||
|
||||
@@ -95,7 +95,9 @@ export const ApontamentosFilters: React.FC<ApontamentosFiltersProps> = ({
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as fases</SelectItem>
|
||||
{uniqueFases.map((fase) => (
|
||||
<SelectItem key={fase} value={fase}>{fase}</SelectItem>
|
||||
<SelectItem key={fase} value={fase}>
|
||||
{fase.toLowerCase().startsWith('fase') || fase.toLowerCase().startsWith('etapa') ? fase : `Fase ${fase}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -35,6 +35,15 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getFaseItem = (apontamento: ApontamentoProducao) => {
|
||||
const rawFase = apontamento.peca?.etapa_fase || (apontamento.componente as any)?.peca?.etapa_fase;
|
||||
if (!rawFase || !String(rawFase).trim()) return null;
|
||||
const str = String(rawFase).trim();
|
||||
return str.toLowerCase().startsWith('fase') || str.toLowerCase().startsWith('etapa')
|
||||
? str
|
||||
: `Fase ${str}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -52,6 +61,21 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
|
||||
<Badge variant="outline">
|
||||
{apontamento.of_number || 'N/A'}
|
||||
</Badge>
|
||||
{getFaseItem(apontamento) ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950/60 dark:text-sky-300 dark:border-sky-800 text-xs font-semibold"
|
||||
>
|
||||
{getFaseItem(apontamento)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground border-dashed text-xs"
|
||||
>
|
||||
Fase -
|
||||
</Badge>
|
||||
)}
|
||||
{apontamento.is_forcado && (
|
||||
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full border ${
|
||||
apontamento.status_confirmacao === 'pendente_confirmacao'
|
||||
@@ -100,7 +124,7 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
|
||||
Tem certeza de que deseja reverter este apontamento?
|
||||
Esta ação não pode ser desfeita e irá remover permanentemente o registro de:
|
||||
<br />
|
||||
<strong>{getMarcaItem(apontamento)}</strong> - {apontamento.quantidade_produzida} unidades
|
||||
<strong>{getMarcaItem(apontamento)}</strong> (OF: {apontamento.of_number || 'N/A'}{getFaseItem(apontamento) ? ` • ${getFaseItem(apontamento)}` : ''}) - {apontamento.quantidade_produzida} unidades
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -89,12 +89,12 @@ export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
|
||||
: apontamentos;
|
||||
|
||||
const fases = apontamentosFiltrados
|
||||
.map(apt => apt.peca?.etapa_fase)
|
||||
.map(apt => apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase)
|
||||
.filter((fase): fase is string => {
|
||||
return typeof fase === 'string' && fase.trim() !== '';
|
||||
})
|
||||
.filter((fase, index, array) => array.indexOf(fase) === index)
|
||||
.sort();
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
|
||||
console.log(`📋 Fases únicas encontradas para OF "${filterOF}": ${fases.length}`, fases);
|
||||
return fases;
|
||||
@@ -166,7 +166,7 @@ export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
|
||||
// Aplicar filtro por Fase APENAS se selecionado
|
||||
if (filterFase && filterFase !== '' && filterFase !== 'all') {
|
||||
const beforeFase = resultado.length;
|
||||
resultado = resultado.filter(apt => apt.peca?.etapa_fase === filterFase);
|
||||
resultado = resultado.filter(apt => (apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase) === filterFase);
|
||||
console.log(`📋 Filtro Fase "${filterFase}": ${beforeFase} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { MapaTermicoKitsView } from './MapaTermicoKitsView';
|
||||
import { PecaKitInfo } from '@/hooks/useKitsProducao';
|
||||
import { Flame } from 'lucide-react';
|
||||
|
||||
interface MapaTermicoKitsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
ofNumber?: string;
|
||||
fase?: string;
|
||||
onSelectPecaParaApontar?: (peca: PecaKitInfo) => void;
|
||||
}
|
||||
|
||||
export const MapaTermicoKitsModal: React.FC<MapaTermicoKitsModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
ofNumber,
|
||||
fase,
|
||||
onSelectPecaParaApontar,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader className="pb-2 border-b border-slate-200 dark:border-slate-800">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg sm:text-xl font-black text-slate-900 dark:text-slate-100 uppercase tracking-tight">
|
||||
<Flame className="w-5 h-5 text-orange-500 fill-orange-500 animate-pulse" />
|
||||
<span>Mapa Térmico de Kits de Montagem</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2">
|
||||
<MapaTermicoKitsView
|
||||
initialOF={ofNumber}
|
||||
initialFase={fase || 'all'}
|
||||
readOnlyOF={Boolean(ofNumber)}
|
||||
onSelectPecaParaApontar={(peca) => {
|
||||
if (onSelectPecaParaApontar) {
|
||||
onSelectPecaParaApontar(peca);
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,681 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Flame,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Layers,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Wrench,
|
||||
Scissors,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { useKitsProducao, PecaKitInfo } from '@/hooks/useKitsProducao';
|
||||
import { useOFsAtivas, OFAtiva } from '@/hooks/useOFsAtivas';
|
||||
|
||||
interface MapaTermicoKitsViewProps {
|
||||
initialOF?: string;
|
||||
initialFase?: string;
|
||||
readOnlyOF?: boolean;
|
||||
onSelectPecaParaApontar?: (peca: PecaKitInfo) => void;
|
||||
}
|
||||
|
||||
export const MapaTermicoKitsView: React.FC<MapaTermicoKitsViewProps> = ({
|
||||
initialOF = '',
|
||||
initialFase = 'all',
|
||||
readOnlyOF = false,
|
||||
onSelectPecaParaApontar,
|
||||
}) => {
|
||||
const { ofsAtivas, isLoading: loadingOFs } = useOFsAtivas();
|
||||
const [selectedOF, setSelectedOF] = useState<string>(initialOF || '');
|
||||
const [selectedFase, setSelectedFase] = useState<string>(initialFase);
|
||||
const [busca, setBusca] = useState<string>('');
|
||||
const [filtroStatus, setFiltroStatus] = useState<'todos' | 'liberados' | 'parciais' | 'travados'>('todos');
|
||||
const [pecasExpandidas, setPecasExpandidas] = useState<Set<string>>(new Set());
|
||||
|
||||
// Atualizar OF padrão se carregar depois
|
||||
React.useEffect(() => {
|
||||
if (initialOF) {
|
||||
setSelectedOF(initialOF);
|
||||
} else if (!selectedOF && ofsAtivas.length > 0) {
|
||||
setSelectedOF(ofsAtivas[0].of_number);
|
||||
}
|
||||
}, [initialOF, ofsAtivas, selectedOF]);
|
||||
|
||||
const { listaKits, resumoKits, loading, refetch } = useKitsProducao(selectedOF);
|
||||
|
||||
// Extrair fases únicas da lista de kits carregada de toda a OF
|
||||
const fasesDisponiveis = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
listaKits.forEach((k) => {
|
||||
if (k.etapa_fase && k.etapa_fase.trim()) s.add(k.etapa_fase.trim());
|
||||
});
|
||||
return Array.from(s).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
}, [listaKits]);
|
||||
|
||||
// Alternar expansão de componentes de uma peça
|
||||
const toggleExpand = (pecaId: string) => {
|
||||
setPecasExpandidas((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(pecaId)) {
|
||||
next.delete(pecaId);
|
||||
} else {
|
||||
next.add(pecaId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandirTodas = () => {
|
||||
setPecasExpandidas(new Set(listaKits.map((k) => k.peca_id)));
|
||||
};
|
||||
|
||||
const recolherTodas = () => {
|
||||
setPecasExpandidas(new Set());
|
||||
};
|
||||
|
||||
// Filtragem combinada (Fase + Status + Busca textual)
|
||||
const pecasFiltradas = useMemo(() => {
|
||||
return listaKits.filter((item) => {
|
||||
// Filtro de fase
|
||||
if (selectedFase && selectedFase !== 'all' && selectedFase !== 'todas') {
|
||||
if (item.etapa_fase !== selectedFase) return false;
|
||||
}
|
||||
|
||||
// Filtro de status
|
||||
if (filtroStatus === 'liberados') {
|
||||
if (item.kits_completos_cortados <= 0) return false;
|
||||
} else if (filtroStatus === 'parciais') {
|
||||
if (
|
||||
item.kits_completos_cortados >= item.quantidade_total ||
|
||||
item.status_termico === 'vermelho'
|
||||
)
|
||||
return false;
|
||||
} else if (filtroStatus === 'travados') {
|
||||
if (item.status_termico !== 'vermelho') return false;
|
||||
}
|
||||
|
||||
// Filtro de busca textual
|
||||
if (busca.trim()) {
|
||||
const t = busca.toLowerCase().trim();
|
||||
const bateMarca = item.marca.toLowerCase().includes(t);
|
||||
const bateDesc = (item.descricao || '').toLowerCase().includes(t);
|
||||
const batePerfil = (item.perfil_principal || '').toLowerCase().includes(t);
|
||||
const bateComp = item.componentes.some((c) =>
|
||||
c.marca_componente.toLowerCase().includes(t) || (c.perfil || '').toLowerCase().includes(t)
|
||||
);
|
||||
if (!bateMarca && !bateDesc && !batePerfil && !bateComp) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [listaKits, selectedFase, filtroStatus, busca]);
|
||||
|
||||
// Formatação de pesos em kg
|
||||
const formatarKg = (valorKg: number) => {
|
||||
if (!valorKg || valorKg <= 0) return '0 kg';
|
||||
return `${valorKg.toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kg`;
|
||||
};
|
||||
|
||||
// KPIs calculados com base nas peças filtradas
|
||||
const resumoExibido = useMemo(() => {
|
||||
let total100 = 0;
|
||||
let totalParciais = 0;
|
||||
let totalTravados = 0;
|
||||
let pesoTot = 0;
|
||||
let pesoCort = 0;
|
||||
let peso100 = 0;
|
||||
let pesoUnLiberadas = 0;
|
||||
let pesoParciais = 0;
|
||||
let pesoNaoIniciados = 0;
|
||||
let unTot = 0;
|
||||
let unLib = 0;
|
||||
|
||||
pecasFiltradas.forEach((item) => {
|
||||
unTot += item.quantidade_total;
|
||||
unLib += item.kits_completos_cortados;
|
||||
pesoTot += item.peso_total_peca;
|
||||
pesoCort += item.peso_cortado_componentes;
|
||||
pesoUnLiberadas += item.peso_kits_liberados;
|
||||
|
||||
if (item.status_termico === 'verde') {
|
||||
total100++;
|
||||
peso100 += item.peso_total_peca;
|
||||
} else if (item.status_termico === 'verde_parcial' || item.status_termico === 'amarelo') {
|
||||
totalParciais++;
|
||||
pesoParciais += item.peso_total_peca;
|
||||
} else {
|
||||
totalTravados++;
|
||||
pesoNaoIniciados += item.peso_total_peca;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalPecas: pecasFiltradas.length,
|
||||
totalKits100Porcento: total100,
|
||||
totalKitsParciais: totalParciais,
|
||||
totalKitsNaoIniciados: totalTravados,
|
||||
pesoTotalOFKg: pesoTot,
|
||||
pesoCortadoTotalKg: pesoCort,
|
||||
peso100PorcentoKg: peso100,
|
||||
pesoUnidadesLiberadasKg: pesoUnLiberadas,
|
||||
pesoParciaisKg: pesoParciais,
|
||||
pesoNaoIniciadosKg: pesoNaoIniciados,
|
||||
percentualGeralOF: pesoTot > 0 ? Math.min(100, Math.round((pesoCort / pesoTot) * 100)) : 0,
|
||||
totalUnidadesTotal: unTot,
|
||||
totalUnidadesLiberadasSolda: unLib,
|
||||
};
|
||||
}, [pecasFiltradas]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 w-full">
|
||||
{/* ──────────────── Barra de Controles e Seletores ──────────────── */}
|
||||
<Card className="border border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-slate-900/90 shadow-sm">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 rounded-xl bg-orange-100 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400">
|
||||
<Flame className="w-5 h-5 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-black text-slate-900 dark:text-slate-100 tracking-tight text-base sm:text-lg flex items-center gap-2">
|
||||
<span>Mapa Térmico de Kits de Produção</span>
|
||||
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:border-orange-800 dark:text-orange-400 text-xs font-bold">
|
||||
Corte ➔ Solda
|
||||
</Badge>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Prontidão física de montagem: kits cortados disponíveis para liberar a Solda
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
className="text-xs gap-1.5 h-8 font-semibold"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span>Atualizar</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-1">
|
||||
{/* Seletor de OF */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
|
||||
Ordem de Fabricação (OF)
|
||||
</label>
|
||||
{readOnlyOF ? (
|
||||
<div className="h-9 px-3 rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-100 dark:bg-slate-800 flex items-center font-bold text-xs">
|
||||
OF {selectedOF}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedOF}
|
||||
onValueChange={(val) => {
|
||||
setSelectedOF(val);
|
||||
setSelectedFase('all');
|
||||
}}
|
||||
disabled={loadingOFs}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs font-semibold">
|
||||
<SelectValue placeholder={loadingOFs ? "Carregando OFs..." : "Selecione uma OF..."}>
|
||||
{selectedOF ? `OF ${selectedOF}` : undefined}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{ofsAtivas.map((o) => (
|
||||
<SelectItem key={o.of_number} value={o.of_number}>
|
||||
OF {o.of_number} {o.cliente ? `• ${o.cliente}` : o.descricao_resumida ? `• ${o.descricao_resumida}` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Seletor de Fase */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
|
||||
Fase da OF
|
||||
</label>
|
||||
<Select value={selectedFase} onValueChange={setSelectedFase}>
|
||||
<SelectTrigger className="h-9 text-xs font-semibold">
|
||||
<SelectValue placeholder="Todas as fases" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as fases</SelectItem>
|
||||
{fasesDisponiveis.map((f) => (
|
||||
<SelectItem key={f} value={f}>
|
||||
{f.toLowerCase().startsWith('fase') ? f : `Fase ${f}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Busca Rápida */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
|
||||
Buscar Marca / Componente
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-3 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Ex: V1, p2, W200..."
|
||||
value={busca}
|
||||
onChange={(e) => setBusca(e.target.value)}
|
||||
className="h-9 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ──────────────── Cards de KPIs do Mapa Térmico ──────────────── */}
|
||||
{resumoExibido && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5 sm:gap-3">
|
||||
{/* KPI 1: 100% Liberadas */}
|
||||
<div className="p-3.5 rounded-2xl bg-emerald-50/80 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800 shadow-sm flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] font-black uppercase text-emerald-700 dark:text-emerald-400">
|
||||
100% Liberadas
|
||||
</span>
|
||||
<span className="text-[11px] font-black text-emerald-800 dark:text-emerald-300 bg-emerald-200/80 dark:bg-emerald-900/70 px-1.5 py-0.5 rounded-md border border-emerald-300/80 dark:border-emerald-700 shadow-2xs">
|
||||
{formatarKg(resumoExibido.peso100PorcentoKg)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-black text-emerald-900 dark:text-emerald-100 mt-1">
|
||||
{resumoExibido.totalKits100Porcento}
|
||||
</div>
|
||||
<div className="text-[10px] text-emerald-700/80 dark:text-emerald-400/80 font-medium truncate">
|
||||
peças com todos os kits cortados
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-900/60 dark:text-emerald-300 shrink-0 ml-2">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 2: Kits Montáveis Liberados */}
|
||||
<div className="p-3.5 rounded-2xl bg-blue-50/80 dark:bg-blue-950/40 border border-blue-200 dark:border-blue-800 shadow-sm flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] font-black uppercase text-blue-700 dark:text-blue-400">
|
||||
Unidades Liberadas
|
||||
</span>
|
||||
<span className="text-[11px] font-black text-blue-800 dark:text-blue-300 bg-blue-200/80 dark:bg-blue-900/70 px-1.5 py-0.5 rounded-md border border-blue-300/80 dark:border-blue-700 shadow-2xs">
|
||||
{formatarKg(resumoExibido.pesoUnidadesLiberadasKg)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-black text-blue-900 dark:text-blue-100 mt-1">
|
||||
{resumoExibido.totalUnidadesLiberadasSolda} <span className="text-xs text-blue-600 font-bold">/ {resumoExibido.totalUnidadesTotal} un</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-blue-700/80 dark:text-blue-400/80 font-medium truncate">
|
||||
kits prontos na bancada de solda
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl bg-blue-100 text-blue-700 dark:bg-blue-900/60 dark:text-blue-300 shrink-0 ml-2">
|
||||
<PackageCheck className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 3: Em Andamento / Quase Prontos */}
|
||||
<div className="p-3.5 rounded-2xl bg-amber-50/80 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 shadow-sm flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] font-black uppercase text-amber-700 dark:text-amber-400">
|
||||
Corte em Andamento
|
||||
</span>
|
||||
<span className="text-[11px] font-black text-amber-800 dark:text-amber-300 bg-amber-200/80 dark:bg-amber-900/70 px-1.5 py-0.5 rounded-md border border-amber-300/80 dark:border-amber-700 shadow-2xs">
|
||||
{formatarKg(resumoExibido.pesoParciaisKg)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-black text-amber-900 dark:text-amber-100 mt-1">
|
||||
{resumoExibido.totalKitsParciais}
|
||||
</div>
|
||||
<div className="text-[10px] text-amber-700/80 dark:text-amber-400/80 font-medium truncate">
|
||||
kits parciais (prioridade de corte)
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-900/60 dark:text-amber-300 shrink-0 ml-2">
|
||||
<Clock className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 4: Travados / 0% */}
|
||||
<div className="p-3.5 rounded-2xl bg-rose-50/80 dark:bg-rose-950/40 border border-rose-200 dark:border-rose-800 shadow-sm flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] font-black uppercase text-rose-700 dark:text-rose-400">
|
||||
Não Iniciados
|
||||
</span>
|
||||
<span className="text-[11px] font-black text-rose-800 dark:text-rose-300 bg-rose-200/80 dark:bg-rose-900/70 px-1.5 py-0.5 rounded-md border border-rose-300/80 dark:border-rose-700 shadow-2xs">
|
||||
{formatarKg(resumoExibido.pesoNaoIniciadosKg)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-black text-rose-900 dark:text-rose-100 mt-1">
|
||||
{resumoExibido.totalKitsNaoIniciados}
|
||||
</div>
|
||||
<div className="text-[10px] text-rose-700/80 dark:text-rose-400/80 font-medium truncate">
|
||||
sem nenhum componente cortado
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl bg-rose-100 text-rose-700 dark:bg-rose-900/60 dark:text-rose-300 shrink-0 ml-2">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ──────────────── Filtros por Status Térmico & Ações em Massa ──────────────── */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap text-xs">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltroStatus('todos')}
|
||||
className={`px-3 py-1.5 rounded-xl font-bold transition-colors ${
|
||||
filtroStatus === 'todos'
|
||||
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900 shadow-sm'
|
||||
: 'bg-slate-100 hover:bg-slate-200 text-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
Todas ({listaKits.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltroStatus('liberados')}
|
||||
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
|
||||
filtroStatus === 'liberados'
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'bg-emerald-50 text-emerald-800 hover:bg-emerald-100 dark:bg-emerald-950/50 dark:text-emerald-300'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400"></span>
|
||||
Liberadas para Solda ({listaKits.filter((k) => k.kits_completos_cortados > 0).length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltroStatus('parciais')}
|
||||
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
|
||||
filtroStatus === 'parciais'
|
||||
? 'bg-amber-600 text-white shadow-sm'
|
||||
: 'bg-amber-50 text-amber-800 hover:bg-amber-100 dark:bg-amber-950/50 dark:text-amber-300'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400"></span>
|
||||
Em Andamento / Quase Prontas ({listaKits.filter((k) => k.status_termico === 'amarelo' || (k.kits_completos_cortados > 0 && k.kits_completos_cortados < k.quantidade_total)).length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltroStatus('travados')}
|
||||
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
|
||||
filtroStatus === 'travados'
|
||||
? 'bg-rose-600 text-white shadow-sm'
|
||||
: 'bg-rose-50 text-rose-800 hover:bg-rose-100 dark:bg-rose-950/50 dark:text-rose-300'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-rose-400"></span>
|
||||
Travadas no Corte ({listaKits.filter((k) => k.status_termico === 'vermelho').length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={expandirTodas} className="h-7 text-[11px] font-semibold text-slate-600 dark:text-slate-400">
|
||||
Expandir Todos
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={recolherTodas} className="h-7 text-[11px] font-semibold text-slate-600 dark:text-slate-400">
|
||||
Recolher Todos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ──────────────── Lista de Peças com Mapa Térmico ──────────────── */}
|
||||
{!loading && listaKits.length > 0 && !listaKits.some((k) => k.componentes && k.componentes.length > 0) && (
|
||||
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-900 dark:text-amber-200 flex items-center gap-3 shadow-xs">
|
||||
<Info className="w-5 h-5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="text-xs leading-relaxed">
|
||||
<strong className="font-bold">Modo Peça Direta:</strong> Esta OF não possui subcomponentes cadastrados no sistema. O avanço no <strong>Corte</strong> e a liberação das peças para a <strong>Solda</strong> são gerenciados diretamente pelo apontamento das peças principais.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="py-16 flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-orange-500 border-t-transparent" />
|
||||
<span className="text-sm font-semibold">Calculando matriz de kits e completude...</span>
|
||||
</div>
|
||||
) : pecasFiltradas.length === 0 ? (
|
||||
<Card className="border border-dashed border-slate-300 dark:border-slate-800 p-8 text-center bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<Layers className="w-10 h-10 text-slate-400 mx-auto mb-2 opacity-60" />
|
||||
<h4 className="font-bold text-slate-700 dark:text-slate-300 text-sm">Nenhuma peça encontrada</h4>
|
||||
<p className="text-xs text-slate-500 mt-1">Tente ajustar a busca ou os filtros de status</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pecasFiltradas.map((peca) => {
|
||||
const isExpanded = pecasExpandidas.has(peca.peca_id);
|
||||
const totalKits = peca.quantidade_total;
|
||||
const kitsProntos = peca.kits_completos_cortados;
|
||||
const pct = peca.percentual_termico;
|
||||
const eh100 = kitsProntos >= totalKits;
|
||||
const temParcial = kitsProntos > 0 && kitsProntos < totalKits;
|
||||
const gargalo = peca.componente_gargalo;
|
||||
|
||||
// Cores térmicas
|
||||
let barColor = 'bg-rose-500';
|
||||
let badgeBg = 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/60 dark:text-rose-300 dark:border-rose-800';
|
||||
if (eh100) {
|
||||
barColor = 'bg-emerald-500';
|
||||
badgeBg = 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/60 dark:text-emerald-300 dark:border-emerald-800';
|
||||
} else if (temParcial) {
|
||||
barColor = 'bg-emerald-500';
|
||||
badgeBg = 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/60 dark:text-blue-300 dark:border-blue-800';
|
||||
} else if (pct >= 40) {
|
||||
barColor = 'bg-amber-500';
|
||||
badgeBg = 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/60 dark:text-amber-300 dark:border-amber-800';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={peca.peca_id}
|
||||
className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900/90 shadow-sm overflow-hidden transition-all hover:border-slate-300 dark:hover:border-slate-700"
|
||||
>
|
||||
{/* Linha Principal do Card */}
|
||||
<div
|
||||
onClick={() => toggleExpand(peca.peca_id)}
|
||||
className="p-4 cursor-pointer flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:bg-slate-50/50 dark:hover:bg-slate-800/40 transition-colors select-none"
|
||||
>
|
||||
{/* Lado Esquerdo: Identificação da Peça */}
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 font-black text-sm shadow-sm ${
|
||||
eh100
|
||||
? 'bg-emerald-500 text-white'
|
||||
: temParcial
|
||||
? 'bg-blue-500 text-white'
|
||||
: pct >= 40
|
||||
? 'bg-amber-500 text-slate-950'
|
||||
: 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{pct}%
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-lg font-black text-slate-900 dark:text-slate-100 tracking-wide">
|
||||
{peca.marca}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[11px] font-bold">
|
||||
{peca.etapa_fase.toLowerCase().startsWith('fase') ? peca.etapa_fase : `Fase ${peca.etapa_fase}`}
|
||||
</Badge>
|
||||
{peca.perfil_principal && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-semibold font-mono">
|
||||
{peca.perfil_principal}
|
||||
</span>
|
||||
)}
|
||||
{peca.tem_componentes === false && (
|
||||
<Badge variant="secondary" className="bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300 text-[10px] font-black">
|
||||
S/M (Sem Montagem)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5 flex-wrap">
|
||||
<span className="line-clamp-1">{peca.descricao || 'Peça Estrutural'}</span>
|
||||
{peca.peso_unitario > 0 && (
|
||||
<span className="font-semibold text-slate-600 dark:text-slate-400 whitespace-nowrap">
|
||||
• {peca.peso_unitario.toFixed(1)} kg/un (Total: {peca.peso_total_peca.toFixed(1)} kg)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lado Direito: Status dos Kits e Métricas */}
|
||||
<div className="flex items-center gap-3 shrink-0 justify-between sm:justify-end">
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end gap-1.5 flex-wrap">
|
||||
<span className={`text-xs font-black px-2.5 py-0.5 rounded-full border ${badgeBg}`}>
|
||||
{eh100
|
||||
? `✅ 100% Liberado (${kitsProntos}/${totalKits} un)`
|
||||
: temParcial
|
||||
? `🟢 ${kitsProntos}/${totalKits} kits liberados na Solda`
|
||||
: pct >= 40
|
||||
? `🟡 0/${totalKits} kits (Corte ${pct}%)`
|
||||
: `🔴 0/${totalKits} kits (Não iniciado)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Gargalo em Destaque */}
|
||||
{gargalo && kitsProntos < totalKits && (
|
||||
<div className="text-[11px] font-semibold text-amber-700 dark:text-amber-400 mt-1 flex items-center justify-end gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-500 shrink-0" />
|
||||
<span>
|
||||
Gargalo: <strong>{gargalo.marca_componente}</strong> (faltam {gargalo.falta_para_prox_kit} un para próx. kit)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-1 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
|
||||
{isExpanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra Térmica de Progresso */}
|
||||
<div className="px-4 pb-3">
|
||||
<div className="w-full bg-slate-100 dark:bg-slate-800 h-2.5 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${barColor}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
title={`Prontidão térmica do kit: ${pct}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ──────────────── Detalhamento dos Componentes do Kit (Expansível) ──────────────── */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pt-2 border-t border-slate-100 dark:border-slate-800/80 bg-slate-50/60 dark:bg-slate-950/40 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300 mb-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Wrench className="w-3.5 h-3.5 text-blue-500" />
|
||||
Componentes Requeridos para {totalKits}x {peca.marca} ({peca.componentes.length} itens no kit):
|
||||
</span>
|
||||
{onSelectPecaParaApontar && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectPecaParaApontar(peca);
|
||||
}}
|
||||
className="h-7 text-xs bg-amber-500 hover:bg-amber-600 text-slate-950 font-black gap-1"
|
||||
>
|
||||
<Scissors className="w-3 h-3" />
|
||||
Apontar no Corte
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{peca.componentes.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 italic py-1">
|
||||
Esta peça não possui subcomponentes cadastrados (é fabricada como peça inteira).
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{peca.componentes.map((comp) => {
|
||||
const isGargalo = gargalo?.id === comp.id && kitsProntos < totalKits;
|
||||
const comp100 = comp.concluido;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className={`p-2.5 rounded-xl border text-xs transition-colors flex items-center justify-between gap-2 ${
|
||||
isGargalo
|
||||
? 'bg-amber-50 dark:bg-amber-950/40 border-amber-400 dark:border-amber-700 shadow-sm'
|
||||
: comp100
|
||||
? 'bg-emerald-50/50 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-800/60'
|
||||
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="font-mono font-black text-slate-900 dark:text-slate-100">
|
||||
{comp.marca_componente}
|
||||
</span>
|
||||
{comp.perfil && (
|
||||
<span className="text-[10px] px-1.5 py-0.2 rounded bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 font-mono">
|
||||
{comp.perfil}
|
||||
</span>
|
||||
)}
|
||||
{isGargalo && (
|
||||
<span className="text-[9px] font-black px-1.5 py-0.5 rounded bg-amber-500 text-slate-950 uppercase tracking-tight">
|
||||
Gargalo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{comp.quantidade_por_peca} un/peça • Total da OF: <strong>{comp.quantidade_total_necessaria} un</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<div className="font-mono font-black text-xs text-slate-800 dark:text-slate-200">
|
||||
{comp.quantidade_cortada} / {comp.quantidade_total_necessaria} un
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-slate-500 dark:text-slate-400">
|
||||
Dá para {comp.kits_possiveis} {comp.kits_possiveis === 1 ? 'kit' : 'kits'} ({comp.percentual_cortado}%)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Truck,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import {
|
||||
validarRomaneioParaEntrega,
|
||||
executarApontamentoRomaneioEntregue,
|
||||
ResultadoValidacaoEntrega
|
||||
} from '@/utils/validacaoRomaneio';
|
||||
|
||||
export interface RomaneioEntregaInfo {
|
||||
id: string;
|
||||
numero_romaneio: string;
|
||||
of_number: string;
|
||||
data_prevista_entrega?: string | null;
|
||||
data_romaneio?: string | null;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface ConfirmacaoEntregaRomaneioModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
romaneio: RomaneioEntregaInfo | null;
|
||||
onSucesso?: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmacaoEntregaRomaneioModal: React.FC<ConfirmacaoEntregaRomaneioModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
romaneio,
|
||||
onSucesso
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [loadingAnalise, setLoadingAnalise] = useState(false);
|
||||
const [analise, setAnalise] = useState<ResultadoValidacaoEntrega | null>(null);
|
||||
const [dataRealEntrega, setDataRealEntrega] = useState<string>('');
|
||||
const [processando, setProcessando] = useState(false);
|
||||
|
||||
// Inicializar dados ao abrir
|
||||
useEffect(() => {
|
||||
if (isOpen && romaneio) {
|
||||
const hoje = new Date().toISOString().split('T')[0];
|
||||
const dataInicial = romaneio.data_prevista_entrega || hoje;
|
||||
setDataRealEntrega(dataInicial);
|
||||
carregarAnalise(romaneio.id, romaneio.of_number);
|
||||
} else {
|
||||
setAnalise(null);
|
||||
setDataRealEntrega('');
|
||||
}
|
||||
}, [isOpen, romaneio]);
|
||||
|
||||
const carregarAnalise = async (romaneioId: string, ofNumber: string) => {
|
||||
try {
|
||||
setLoadingAnalise(true);
|
||||
const res = await validarRomaneioParaEntrega(romaneioId, ofNumber);
|
||||
setAnalise(res);
|
||||
} catch (e: any) {
|
||||
console.error('Erro ao analisar romaneio:', e);
|
||||
toast.error('Erro ao verificar status das peças: ' + (e?.message || 'Falha de comunicação'));
|
||||
} finally {
|
||||
setLoadingAnalise(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecutar = async (autorizarCascata: boolean, apontarApenasLiberadas: boolean) => {
|
||||
if (!romaneio) return;
|
||||
if (!dataRealEntrega) {
|
||||
toast.error('Informe a data real de entrega para gravar o apontamento');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessando(true);
|
||||
const res = await executarApontamentoRomaneioEntregue({
|
||||
romaneioId: romaneio.id,
|
||||
ofNumber: romaneio.of_number,
|
||||
numeroRomaneio: romaneio.numero_romaneio,
|
||||
dataEntregaReal: dataRealEntrega,
|
||||
autorizarCascata,
|
||||
apontarApenasLiberadas,
|
||||
userId: user?.id
|
||||
});
|
||||
|
||||
if (res.sucesso) {
|
||||
toast.success(res.mensagem);
|
||||
// Atualizar queries ativas para atualizar tabelas e gráficos
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['romaneios'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['apontamentos'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard-producao'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['pecas'] })
|
||||
]);
|
||||
|
||||
onClose();
|
||||
if (onSucesso) onSucesso();
|
||||
} else {
|
||||
toast.warning(res.mensagem);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Erro ao processar entrega:', e);
|
||||
toast.error('Erro ao concluir apontamento: ' + (e?.message || 'Falha no banco de dados'));
|
||||
} finally {
|
||||
setProcessando(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!romaneio) return null;
|
||||
|
||||
const dataPrevistaFormatada = romaneio.data_prevista_entrega
|
||||
? new Date(romaneio.data_prevista_entrega + 'T12:00:00').toLocaleDateString('pt-BR')
|
||||
: null;
|
||||
|
||||
const hoje = new Date().toISOString().split('T')[0];
|
||||
const dataDiferenteDePrevista = romaneio.data_prevista_entrega && dataRealEntrega !== romaneio.data_prevista_entrega;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col p-6">
|
||||
<DialogHeader className="border-b pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
|
||||
<Truck className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<span>Confirmar Entrega & Apontamento de Expedição</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm text-muted-foreground mt-0.5">
|
||||
Romaneio <strong>{romaneio.numero_romaneio}</strong> • OF <strong>{romaneio.of_number}</strong>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4 overflow-y-auto flex-1 pr-1">
|
||||
{/* Seção 1: Data Real da Entrega */}
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<Label htmlFor="data-real-entrega" className="font-semibold text-sm flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
Data Real da Entrega *
|
||||
</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{romaneio.data_prevista_entrega && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => setDataRealEntrega(romaneio.data_prevista_entrega!)}
|
||||
>
|
||||
Usar Data Prevista ({dataPrevistaFormatada})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => setDataRealEntrega(hoje)}
|
||||
>
|
||||
Usar Hoje
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
|
||||
<Input
|
||||
id="data-real-entrega"
|
||||
type="date"
|
||||
value={dataRealEntrega}
|
||||
onChange={(e) => setDataRealEntrega(e.target.value)}
|
||||
className="h-9 font-medium"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{dataDiferenteDePrevista ? (
|
||||
<span className="text-amber-600 dark:text-amber-400 font-medium flex items-center gap-1">
|
||||
<Info className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
Data real difere da data prevista ({dataPrevistaFormatada}).
|
||||
</span>
|
||||
) : (
|
||||
<span>Os apontamentos serão gravados exatamente nesta data.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seção 2: Diagnóstico dos Processos de Fabricação */}
|
||||
{loadingAnalise ? (
|
||||
<div className="p-8 text-center space-y-2">
|
||||
<Loader2 className="h-7 w-7 animate-spin mx-auto text-primary" />
|
||||
<p className="text-xs text-muted-foreground">Analisando processos de fabricação das peças...</p>
|
||||
</div>
|
||||
) : analise ? (
|
||||
<div className="space-y-3">
|
||||
{analise.totalPendentes === 0 ? (
|
||||
// Cenário A: Todas as peças liberadas
|
||||
<div className="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-lg flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-emerald-700 dark:text-emerald-300 text-sm">
|
||||
Tudo pronto para Expedição!
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Todas as <strong>{analise.totalItens} peças</strong> deste romaneio já concluíram os processos de fabricação anteriores na fábrica. Ao confirmar, o apontamento de Expedição será registrado imediatamente para todas as unidades na data informada.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Cenário B: Existem peças com pendências nos processos anteriores
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-amber-700 dark:text-amber-300 text-sm">
|
||||
Peças Pendentes nos Processos Anteriores ({analise.totalPendentes} de {analise.totalItens} itens)
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Algumas peças deste romaneio ainda não possuem apontamentos completos nos processos de fabricação anteriores (Corte, Solda ou Pintura/Galv).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabela de Peças Pendentes */}
|
||||
<div className="border rounded-md overflow-hidden bg-card">
|
||||
<div className="bg-muted/50 px-3 py-2 text-xs font-semibold text-foreground border-b flex justify-between items-center">
|
||||
<span>Detalhamento das Peças com Pendência</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{analise.totalPendentes} peça(s) a regularizar
|
||||
</Badge>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[220px]">
|
||||
<table className="w-full text-xs text-left">
|
||||
<thead className="bg-muted/30 text-muted-foreground uppercase border-b sticky top-0">
|
||||
<tr>
|
||||
<th className="py-2 px-3">Marca</th>
|
||||
<th className="py-2 px-3 text-center">Fase</th>
|
||||
<th className="py-2 px-3 text-center">No Romaneio</th>
|
||||
<th className="py-2 px-3">Processos que Faltam Apontar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{analise.pecasAnalise
|
||||
?.filter(p => !p.liberada)
|
||||
.map((peca) => (
|
||||
<tr key={peca.peca_id} className="hover:bg-accent/40">
|
||||
<td className="py-2 px-3 font-semibold text-foreground">
|
||||
{peca.marca}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
Fase {peca.fase}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center font-bold">
|
||||
{peca.quantidade_expedida} un
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{peca.processosFaltantes.map((f, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="outline"
|
||||
className="text-[10px] bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
|
||||
>
|
||||
{f.nome}: faltam {f.qtdFaltante} un
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Pergunta de Decisão com Destaque */}
|
||||
<div className="p-3.5 bg-blue-500/10 border border-blue-500/30 rounded-lg flex items-center gap-3">
|
||||
<Sparkles className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div className="text-xs">
|
||||
<p className="font-semibold text-blue-900 dark:text-blue-200">
|
||||
Autorizar Apontamento em Cascata?
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
O sistema irá criar automaticamente os apontamentos faltantes nos processos pendentes para regularizar a produção e registrar a entrega completa de todas as peças na Expedição.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-3 flex flex-wrap gap-2 justify-between items-center sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={processando}
|
||||
size="sm"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap gap-2 ml-auto">
|
||||
{analise && analise.totalPendentes === 0 ? (
|
||||
// Botão para caso sem pendências
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleExecutar(false, false)}
|
||||
disabled={processando || !dataRealEntrega}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold text-xs shadow-sm"
|
||||
size="sm"
|
||||
>
|
||||
{processando ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Confirmar Entrega & Apontar Expedição
|
||||
</Button>
|
||||
) : analise && analise.totalPendentes > 0 ? (
|
||||
<>
|
||||
{analise.totalLiberadas && analise.totalLiberadas > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => handleExecutar(false, true)}
|
||||
disabled={processando || !dataRealEntrega}
|
||||
className="text-xs"
|
||||
size="sm"
|
||||
title="Aponta somente as peças que já concluíram Pintura"
|
||||
>
|
||||
Apontar Apenas Liberadas ({analise.totalLiberadas})
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleExecutar(true, false)}
|
||||
disabled={processando || !dataRealEntrega}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs shadow-sm flex items-center gap-1.5"
|
||||
size="sm"
|
||||
>
|
||||
{processando ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Autorizar Cascata e Apontar Todas ({analise.totalItens} peças)
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -28,7 +28,10 @@ interface PecaDisponivel {
|
||||
id: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
perfil_principal?: string | null;
|
||||
etapa_fase: string;
|
||||
quantidade_total_peca: number;
|
||||
quantidade_ja_expedida: number;
|
||||
quantidade_disponivel: number;
|
||||
peso_unitario: number;
|
||||
comprimento?: number | null;
|
||||
@@ -104,18 +107,24 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
peca_id,
|
||||
componente_id,
|
||||
tipo_apontamento,
|
||||
quantidade_produzida,
|
||||
processo_id,
|
||||
processo:processos_fabricacao!inner!apontamentos_producao_processo_id_fkey(nome),
|
||||
componente:componentes_peca!apontamentos_producao_componente_id_fkey(id, quantidade_por_peca),
|
||||
peca:pecas!inner!apontamentos_producao_peca_id_fkey(
|
||||
id,
|
||||
marca,
|
||||
descricao,
|
||||
perfil_principal,
|
||||
etapa_fase,
|
||||
quantidade,
|
||||
peso_unitario,
|
||||
comprimento,
|
||||
prioridade,
|
||||
of_number
|
||||
of_number,
|
||||
tem_componentes
|
||||
)
|
||||
`)
|
||||
.eq('peca.of_number', romaneio.of_number)
|
||||
@@ -141,49 +150,99 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(expedidosData as any[]).forEach((item: any) => {
|
||||
const pid = item.peca_id;
|
||||
const qtd = item.quantidade_expedida || 0;
|
||||
const qtd = Number(item.quantidade_expedida) || 0;
|
||||
const current = expedidosMap.get(pid) || 0;
|
||||
expedidosMap.set(pid, current + qtd);
|
||||
});
|
||||
}
|
||||
|
||||
// Agrupar por peça e calcular quantidade total produzida
|
||||
const pecasAgrupadas = new Map<string, PecaDisponivel>();
|
||||
|
||||
// 3. Agrupar por peça principal (NUNCA somar unidades de subcomponentes na peça principal)
|
||||
const pecasAgrupadas = new Map<
|
||||
string,
|
||||
{
|
||||
peca: any;
|
||||
qtdPecaDireta: number;
|
||||
componentesCortados: Map<string, { cortado: number; porPeca: number }>;
|
||||
}
|
||||
>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(apontamentosData as any[])?.forEach((apontamento: any) => {
|
||||
const pecaId = apontamento.peca?.id;
|
||||
const pecaObj = apontamento.peca;
|
||||
const pecaId = pecaObj?.id;
|
||||
if (!pecaId) return;
|
||||
|
||||
const quantidade = apontamento.quantidade_produzida || 0;
|
||||
|
||||
if (pecasAgrupadas.has(pecaId)) {
|
||||
const pecaExistente = pecasAgrupadas.get(pecaId)!;
|
||||
pecaExistente.quantidade_disponivel += quantidade; // Aqui salva o Bruto temporariamente
|
||||
} else {
|
||||
if (!pecasAgrupadas.has(pecaId)) {
|
||||
pecasAgrupadas.set(pecaId, {
|
||||
id: apontamento.peca.id,
|
||||
marca: apontamento.peca.marca,
|
||||
descricao: apontamento.peca.descricao || '',
|
||||
etapa_fase: apontamento.peca.etapa_fase || '',
|
||||
quantidade_disponivel: quantidade, // Bruto temporário
|
||||
peso_unitario: apontamento.peca.peso_unitario || 0,
|
||||
comprimento: apontamento.peca.comprimento || null,
|
||||
prioridade: apontamento.peca.prioridade || 'P4'
|
||||
peca: pecaObj,
|
||||
qtdPecaDireta: 0,
|
||||
componentesCortados: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
const grupo = pecasAgrupadas.get(pecaId)!;
|
||||
const quantidade = Number(apontamento.quantidade_produzida) || 0;
|
||||
const isComponente =
|
||||
apontamento.tipo_apontamento === 'componente' || Boolean(apontamento.componente_id);
|
||||
|
||||
if (isComponente) {
|
||||
const compId = apontamento.componente_id || 'comp_default';
|
||||
const porPeca = Math.max(1, Number(apontamento.componente?.quantidade_por_peca) || 1);
|
||||
const atualComp = grupo.componentesCortados.get(compId)?.cortado || 0;
|
||||
grupo.componentesCortados.set(compId, {
|
||||
cortado: atualComp + quantidade,
|
||||
porPeca
|
||||
});
|
||||
} else {
|
||||
grupo.qtdPecaDireta += quantidade;
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Calcular o saldo Real (Produzido - Expedido) e limpar zerados
|
||||
// 4. Calcular o saldo Real da peça principal (Produzido da Peça Principal - Expedido em Romaneios)
|
||||
const pecasDisponiveisFinal: PecaDisponivel[] = [];
|
||||
pecasAgrupadas.forEach((peca, pecaId) => {
|
||||
pecasAgrupadas.forEach((grupo, pecaId) => {
|
||||
const pecaObj = grupo.peca;
|
||||
const qtdTotalPeca = Math.max(1, Number(pecaObj.quantidade) || 1);
|
||||
|
||||
let qtdProduzidaPecaPrincipal = 0;
|
||||
if (grupo.qtdPecaDireta > 0) {
|
||||
// Apontamento direto da peça principal (ou kit sincronizado)
|
||||
qtdProduzidaPecaPrincipal = Math.min(qtdTotalPeca, grupo.qtdPecaDireta);
|
||||
} else if (grupo.componentesCortados.size > 0) {
|
||||
// Caso haja apenas apontamentos de subcomponentes no processo (ex: Corte antes da soldagem),
|
||||
// converte para a quantidade equivalente da peça principal (limitada à quantidade total da peça na OF)
|
||||
let maxPecasEquivalentes = 0;
|
||||
grupo.componentesCortados.forEach(({ cortado, porPeca }) => {
|
||||
const equivalentes = Math.floor(cortado / porPeca);
|
||||
if (equivalentes > maxPecasEquivalentes) {
|
||||
maxPecasEquivalentes = equivalentes;
|
||||
}
|
||||
});
|
||||
qtdProduzidaPecaPrincipal = Math.min(
|
||||
qtdTotalPeca,
|
||||
maxPecasEquivalentes > 0 ? maxPecasEquivalentes : qtdTotalPeca
|
||||
);
|
||||
}
|
||||
|
||||
const qtdExpedida = expedidosMap.get(pecaId) || 0;
|
||||
const saldoReal = peca.quantidade_disponivel - qtdExpedida;
|
||||
|
||||
const saldoReal = Math.min(
|
||||
qtdTotalPeca - qtdExpedida,
|
||||
qtdProduzidaPecaPrincipal - qtdExpedida
|
||||
);
|
||||
|
||||
if (saldoReal > 0) {
|
||||
pecasDisponiveisFinal.push({
|
||||
...peca,
|
||||
quantidade_disponivel: saldoReal // Retorna apenas o saldo disponível
|
||||
id: pecaObj.id,
|
||||
marca: pecaObj.marca,
|
||||
descricao: pecaObj.descricao || '',
|
||||
perfil_principal: pecaObj.perfil_principal || null,
|
||||
etapa_fase: pecaObj.etapa_fase || '',
|
||||
quantidade_total_peca: qtdTotalPeca,
|
||||
quantidade_ja_expedida: qtdExpedida,
|
||||
quantidade_disponivel: saldoReal,
|
||||
peso_unitario: Number(pecaObj.peso_unitario) || 0,
|
||||
comprimento: pecaObj.comprimento || null,
|
||||
prioridade: pecaObj.prioridade || 'P4'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -344,6 +403,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
quantidade_expedida: peca.quantidade_disponivel,
|
||||
peso_unitario: peca.peso_unitario,
|
||||
peso_total: peca.quantidade_disponivel * peca.peso_unitario,
|
||||
comprimento: peca.comprimento || null,
|
||||
marca: peca.marca,
|
||||
descricao: peca.descricao,
|
||||
fase: peca.etapa_fase
|
||||
@@ -400,6 +460,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
quantidade_expedida: quantidade,
|
||||
peso_unitario: pecaSelecionada.peso_unitario,
|
||||
peso_total: quantidade * pecaSelecionada.peso_unitario,
|
||||
comprimento: pecaSelecionada.comprimento || null,
|
||||
marca: pecaSelecionada.marca,
|
||||
descricao: pecaSelecionada.descricao,
|
||||
fase: pecaSelecionada.etapa_fase
|
||||
@@ -499,6 +560,10 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
|
||||
const qtdTotalPecas = itensPeca.reduce((total, item) => total + item.quantidade, 0);
|
||||
const pesoTotalPecas = itensPeca.reduce((total, item) => total + item.peso_total, 0);
|
||||
const maiorComprimentoPecas = itensPeca.reduce(
|
||||
(max, item) => Math.max(max, Number(item.comprimento) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -629,7 +694,16 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
<Label className={`font-medium mb-2 block ${isMobile ? 'text-sm' : 'text-sm'}`}>
|
||||
Peça Disponível
|
||||
</Label>
|
||||
<Select value={pecaDisponivel} onValueChange={setPecaDisponivel}>
|
||||
<Select
|
||||
value={pecaDisponivel}
|
||||
onValueChange={(val) => {
|
||||
setPecaDisponivel(val);
|
||||
const pSel = pecasDisponiveis.find((p) => p.id === val);
|
||||
if (pSel) {
|
||||
setQuantidadePeca(String(pSel.quantidade_disponivel));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={isMobile ? 'h-10' : 'h-11'}>
|
||||
<SelectValue placeholder="Selecionar peça" />
|
||||
</SelectTrigger>
|
||||
@@ -646,13 +720,30 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
pecasFiltradas.map((peca) => {
|
||||
const limite = romaneio.comprimento_maximo_veiculo || 12000;
|
||||
const naoCabe = Boolean(peca.comprimento && peca.comprimento > limite);
|
||||
const descClean = (peca.descricao || '').trim();
|
||||
const perfilClean = (peca.perfil_principal || '').trim();
|
||||
const descricaoCompleta =
|
||||
descClean && perfilClean && descClean.toLowerCase() !== perfilClean.toLowerCase()
|
||||
? `${descClean} - ${perfilClean}`
|
||||
: descClean || perfilClean || 'Peça Principal';
|
||||
const faseFormatada = peca.etapa_fase
|
||||
? peca.etapa_fase.toLowerCase().startsWith('fase')
|
||||
? peca.etapa_fase
|
||||
: `Fase ${peca.etapa_fase}`
|
||||
: '';
|
||||
const infoSaldo =
|
||||
peca.quantidade_ja_expedida > 0
|
||||
? `Disp: ${peca.quantidade_disponivel} de ${peca.quantidade_total_peca} un`
|
||||
: `Qtd Disp: ${peca.quantidade_disponivel} un`;
|
||||
|
||||
return (
|
||||
<SelectItem key={peca.id} value={peca.id}>
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<span className="truncate">
|
||||
{peca.marca} - {peca.descricao} (Qtd: {peca.quantidade_disponivel})
|
||||
<span className="truncate font-medium">
|
||||
{peca.marca}
|
||||
{faseFormatada ? ` [${faseFormatada}]` : ''} - {descricaoCompleta} ({infoSaldo})
|
||||
</span>
|
||||
{peca.comprimento && (
|
||||
{peca.comprimento != null && Number(peca.comprimento) > 0 && (
|
||||
<span className={`text-[11px] font-mono shrink-0 ml-2 px-1.5 py-0.5 rounded ${
|
||||
naoCabe ? 'bg-destructive/20 text-destructive font-bold' : 'text-muted-foreground bg-muted'
|
||||
}`}>
|
||||
@@ -733,6 +824,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
|
||||
<Badge variant="outline" className={isMobile ? 'text-xs px-2 py-1' : ''}>
|
||||
Peso Total: {pesoTotalPecas.toFixed(2)} kg
|
||||
</Badge>
|
||||
<Badge variant="outline" className={isMobile ? 'text-xs px-2 py-1' : ''}>
|
||||
Maior Comprimento: {maiorComprimentoPecas > 0 ? `${maiorComprimentoPecas} mm` : '-'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Edit2, Trash2, X } from 'lucide-react';
|
||||
import { Edit2, Trash2, Check, X, Package } from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
|
||||
@@ -20,6 +21,7 @@ interface ItemRomaneioPeca {
|
||||
id: string;
|
||||
peca_id: string;
|
||||
quantidade_expedida: number;
|
||||
quantidade_max_peca?: number;
|
||||
peso_unitario: number;
|
||||
peso_total: number;
|
||||
comprimento?: number | null;
|
||||
@@ -39,13 +41,16 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
|
||||
|
||||
const [itensPeca, setItensPeca] = useState<ItemRomaneioPeca[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingItemId, setEditingItemId] = useState<string | null>(null);
|
||||
const [editQuantidade, setEditQuantidade] = useState<string>('');
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
||||
const carregarItensPecas = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('itens_romaneio_pecas')
|
||||
.select('*, pecas(comprimento)')
|
||||
.select('*, pecas(comprimento, prioridade, quantidade)')
|
||||
.eq('romaneio_id', romaneio.id)
|
||||
.order('marca');
|
||||
|
||||
@@ -56,13 +61,14 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
|
||||
id: item.id,
|
||||
peca_id: item.peca_id,
|
||||
quantidade_expedida: item.quantidade_expedida,
|
||||
quantidade_max_peca: item.pecas?.quantidade || undefined,
|
||||
peso_unitario: item.peso_unitario,
|
||||
peso_total: item.peso_total,
|
||||
comprimento: item.pecas?.comprimento || item.comprimento || null,
|
||||
marca: item.marca,
|
||||
descricao: item.descricao || '',
|
||||
fase: item.fase || '',
|
||||
prioridade: 'P4'
|
||||
prioridade: item.pecas?.prioridade || 'P4'
|
||||
}));
|
||||
setItensPeca(itensFormatados);
|
||||
}
|
||||
@@ -76,8 +82,10 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setEditingItemId(null);
|
||||
carregarItensPecas();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, romaneio.id]);
|
||||
|
||||
const handleRemoverItem = async (itemId: string) => {
|
||||
@@ -99,21 +107,68 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
|
||||
};
|
||||
|
||||
const handleEditarItem = (item: ItemRomaneioPeca) => {
|
||||
// Funcionalidade de edição pode ser implementada aqui
|
||||
toast.info('Funcionalidade de edição em desenvolvimento');
|
||||
setEditingItemId(item.id);
|
||||
setEditQuantidade(String(item.quantidade_expedida));
|
||||
};
|
||||
|
||||
const handleCancelarEdicao = () => {
|
||||
setEditingItemId(null);
|
||||
setEditQuantidade('');
|
||||
};
|
||||
|
||||
const handleSalvarEdicao = async (item: ItemRomaneioPeca) => {
|
||||
const novaQtd = parseInt(editQuantidade, 10);
|
||||
if (isNaN(novaQtd) || novaQtd <= 0) {
|
||||
toast.error('Informe uma quantidade válida maior que zero');
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.quantidade_max_peca && novaQtd > item.quantidade_max_peca) {
|
||||
toast.error(`A quantidade não pode exceder o total da peça na OF (${item.quantidade_max_peca} un)`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingEdit(true);
|
||||
const novoPesoTotal = novaQtd * item.peso_unitario;
|
||||
const { error } = await supabase
|
||||
.from('itens_romaneio_pecas')
|
||||
.update({
|
||||
quantidade_expedida: novaQtd,
|
||||
peso_total: novoPesoTotal
|
||||
})
|
||||
.eq('id', item.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success(`Quantidade da peça ${item.marca} atualizada para ${novaQtd} un`);
|
||||
setEditingItemId(null);
|
||||
setEditQuantidade('');
|
||||
carregarItensPecas();
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
console.error('Erro ao atualizar item:', error);
|
||||
toast.error('Erro ao atualizar quantidade da peça');
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPrioridadeColor = (prioridade: string) => {
|
||||
switch (prioridade) {
|
||||
case 'P1': return 'bg-red-100 text-red-800';
|
||||
case 'P2': return 'bg-orange-100 text-orange-800';
|
||||
case 'P3': return 'bg-blue-100 text-blue-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
case 'P1': return 'bg-red-100 text-red-800 border-red-200 dark:bg-red-950/60 dark:text-red-300 dark:border-red-800';
|
||||
case 'P2': return 'bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-950/60 dark:text-orange-300 dark:border-orange-800';
|
||||
case 'P3': return 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950/60 dark:text-blue-300 dark:border-blue-800';
|
||||
default: return 'bg-slate-100 text-slate-800 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700';
|
||||
}
|
||||
};
|
||||
|
||||
const qtdTotal = itensPeca.reduce((total, item) => total + item.quantidade_expedida, 0);
|
||||
const pesoTotal = itensPeca.reduce((total, item) => total + item.peso_total, 0);
|
||||
const maiorComprimento = itensPeca.reduce(
|
||||
(max, item) => Math.max(max, Number(item.comprimento) || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
@@ -122,47 +177,58 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
|
||||
? 'w-[95vw] max-w-none h-[95vh] m-2'
|
||||
: isTablet
|
||||
? 'w-[90vw] max-w-5xl h-[90vh]'
|
||||
: 'max-w-6xl h-[95vh]'
|
||||
: 'max-w-6xl h-[90vh]'
|
||||
}
|
||||
bg-slate-900 text-white border-slate-700 overflow-hidden flex flex-col
|
||||
bg-background text-foreground border-border shadow-xl overflow-hidden flex flex-col
|
||||
`}>
|
||||
<DialogHeader className="border-b border-slate-700 pb-3 shrink-0">
|
||||
<DialogHeader className="border-b border-border pb-3 shrink-0 pr-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<DialogTitle className={`font-bold text-white ${isMobile ? 'text-lg' : 'text-xl'}`}>
|
||||
<DialogTitle className={`font-bold text-foreground flex items-center gap-2 ${isMobile ? 'text-lg' : 'text-xl'}`}>
|
||||
<Package className="h-5 w-5 text-primary shrink-0" />
|
||||
<span className="truncate">
|
||||
Peças do Romaneio {romaneio.numero_romaneio} - OF: {romaneio.of_number}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`text-gray-400 hover:text-white p-0 ${isMobile ? 'h-6 w-6' : 'h-8 w-8'}`}
|
||||
>
|
||||
<X className={isMobile ? 'h-3 w-3' : 'h-4 w-4'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={`flex gap-2 mt-3 ${isMobile ? 'flex-wrap' : ''}`}>
|
||||
<Badge variant="outline" className={`border-blue-500 text-blue-400 ${isMobile ? 'text-xs px-2 py-1' : ''}`}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-400 dark:border-blue-500/50 font-semibold ${
|
||||
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
|
||||
}`}
|
||||
>
|
||||
Qtd Total: {qtdTotal}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`border-green-500 text-green-400 ${isMobile ? 'text-xs px-2 py-1' : ''}`}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 dark:border-emerald-500/50 font-semibold ${
|
||||
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
|
||||
}`}
|
||||
>
|
||||
Peso Total: {pesoTotal.toFixed(2)} kg
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400 dark:border-amber-500/50 font-semibold ${
|
||||
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
|
||||
}`}
|
||||
>
|
||||
Maior Comprimento: {maiorComprimento > 0 ? `${maiorComprimento} mm` : '-'}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-base'}`}>
|
||||
<div className={`text-muted-foreground ${isMobile ? 'text-sm' : 'text-base'}`}>
|
||||
Carregando...
|
||||
</div>
|
||||
</div>
|
||||
) : itensPeca.length === 0 ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-base'}`}>
|
||||
<div className={`text-muted-foreground ${isMobile ? 'text-sm' : 'text-base'}`}>
|
||||
Nenhuma peça encontrada no romaneio
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +237,7 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
|
||||
<div className="space-y-1 p-1 pb-4">
|
||||
{/* Cabeçalho da tabela - Desktop/Tablet */}
|
||||
{!isMobile && (
|
||||
<div className={`grid gap-3 font-medium text-gray-400 bg-slate-800 p-3 rounded-t-md sticky top-0 z-10 ${
|
||||
<div className={`grid gap-3 font-semibold text-muted-foreground bg-muted/80 dark:bg-slate-800/90 border border-border p-3 rounded-t-md sticky top-0 z-10 backdrop-blur-sm ${
|
||||
isTablet ? 'grid-cols-9 text-xs' : 'grid-cols-11 text-xs'
|
||||
}`}>
|
||||
<div>Marca</div>
|
||||
@@ -190,120 +256,209 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
|
||||
{itensPeca.map((item, index) => {
|
||||
const limite = romaneio.comprimento_maximo_veiculo || 12000;
|
||||
const naoCabe = Boolean(item.comprimento && item.comprimento > limite);
|
||||
const isEditing = editingItemId === item.id;
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{isMobile ? (
|
||||
// Layout mobile - card style
|
||||
<div className={`p-3 border rounded-md mb-2 ${
|
||||
naoCabe ? 'border-red-500/50 bg-red-950/20' : 'border-slate-700'
|
||||
<div className={`p-3 border rounded-md mb-2 transition-colors ${
|
||||
naoCabe
|
||||
? 'border-destructive/50 bg-destructive/10'
|
||||
: 'border-border'
|
||||
} ${
|
||||
index % 2 === 0 ? 'bg-slate-800/20' : 'bg-slate-800/40'
|
||||
index % 2 === 0 ? 'bg-card' : 'bg-muted/30'
|
||||
}`}>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-white text-sm flex items-center gap-1.5">
|
||||
<div className="font-semibold text-foreground text-sm flex items-center gap-1.5">
|
||||
{item.marca}
|
||||
{naoCabe && (
|
||||
<span className="text-[10px] bg-red-600 text-white font-bold px-1.5 py-0.5 rounded">
|
||||
<span className="text-[10px] bg-destructive text-destructive-foreground font-bold px-1.5 py-0.5 rounded">
|
||||
Excede Veículo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-300 text-xs">{item.fase}</div>
|
||||
<div className="text-muted-foreground text-xs">Fase {item.fase}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditarItem(item)}
|
||||
className="h-6 w-6 p-0 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoverItem(item.id)}
|
||||
className="h-6 w-6 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={savingEdit}
|
||||
onClick={() => handleSalvarEdicao(item)}
|
||||
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
|
||||
title="Salvar quantidade"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={savingEdit}
|
||||
onClick={handleCancelarEdicao}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:bg-muted"
|
||||
title="Cancelar"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditarItem(item)}
|
||||
className="h-7 w-7 p-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-500/15"
|
||||
title="Editar quantidade"
|
||||
>
|
||||
<Edit2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoverItem(item.id)}
|
||||
className="h-7 w-7 p-0 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-500/15"
|
||||
title="Remover peça"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-300 text-xs mb-2 line-clamp-2">
|
||||
<div className="text-muted-foreground text-xs mb-2 line-clamp-2">
|
||||
{item.descricao}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
<div className="grid grid-cols-4 gap-2 text-xs items-center">
|
||||
<div>
|
||||
<div className="text-gray-400">Comp:</div>
|
||||
<div className={`font-mono font-medium ${naoCabe ? 'text-red-400 font-bold' : 'text-gray-300'}`}>
|
||||
<div className="text-muted-foreground">Comp:</div>
|
||||
<div className={`font-mono font-medium ${naoCabe ? 'text-destructive font-bold' : 'text-foreground'}`}>
|
||||
{item.comprimento ? `${item.comprimento}mm` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400">Qtd:</div>
|
||||
<div className="font-bold text-blue-400">{item.quantidade_expedida}</div>
|
||||
<div className="text-muted-foreground">Qtd:</div>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={item.quantidade_max_peca}
|
||||
value={editQuantidade}
|
||||
onChange={(e) => setEditQuantidade(e.target.value)}
|
||||
className="h-7 w-16 text-center text-xs px-1"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="font-bold text-blue-600 dark:text-blue-400">{item.quantidade_expedida}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400">Peso Unit:</div>
|
||||
<div className="text-gray-300">{item.peso_unitario.toFixed(2)} kg</div>
|
||||
<div className="text-muted-foreground">Peso Unit:</div>
|
||||
<div className="text-foreground">{item.peso_unitario.toFixed(2)} kg</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400">Peso Total:</div>
|
||||
<div className="font-medium text-white">{item.peso_total.toFixed(2)} kg</div>
|
||||
<div className="text-muted-foreground">Peso Total:</div>
|
||||
<div className="font-semibold text-foreground">{item.peso_total.toFixed(2)} kg</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Layout desktop/tablet - table style
|
||||
<div className={`grid gap-3 text-sm p-3 border-b border-slate-700 hover:bg-slate-800/50 transition-colors ${
|
||||
index % 2 === 0 ? 'bg-slate-800/20' : ''
|
||||
} ${isTablet ? 'grid-cols-9' : 'grid-cols-11'} ${naoCabe ? 'bg-red-950/20' : ''}`}>
|
||||
<div className="font-medium text-white truncate flex items-center gap-1">
|
||||
<div className={`grid gap-3 items-center text-sm p-3 border-b border-x border-border hover:bg-muted/50 transition-colors ${
|
||||
index % 2 === 0 ? 'bg-card' : 'bg-muted/20'
|
||||
} ${isTablet ? 'grid-cols-9' : 'grid-cols-11'} ${naoCabe ? 'bg-destructive/10 border-destructive/40' : ''}`}>
|
||||
<div className="font-semibold text-foreground truncate flex items-center gap-1">
|
||||
{item.marca}
|
||||
{naoCabe && <span title="Não cabe no veículo" className="text-red-400 text-xs">⚠️</span>}
|
||||
{naoCabe && <span title="Não cabe no veículo" className="text-destructive text-xs">⚠️</span>}
|
||||
</div>
|
||||
<div className="text-gray-300 truncate">{item.fase}</div>
|
||||
<div className={`text-gray-300 truncate ${isTablet ? 'col-span-1' : 'col-span-2'}`}>
|
||||
<div className="text-muted-foreground truncate">{item.fase}</div>
|
||||
<div className={`text-foreground truncate ${isTablet ? 'col-span-1' : 'col-span-2'}`}>
|
||||
{item.descricao}
|
||||
</div>
|
||||
<div className={`text-right font-mono text-xs ${naoCabe ? 'text-red-400 font-bold' : 'text-gray-300'}`}>
|
||||
<div className={`text-right font-mono text-xs ${naoCabe ? 'text-destructive font-bold' : 'text-muted-foreground'}`}>
|
||||
{item.comprimento ? `${item.comprimento}` : '-'}
|
||||
</div>
|
||||
<div className="text-center font-bold text-blue-400">
|
||||
{item.quantidade_expedida}
|
||||
<div className="text-center font-bold text-blue-600 dark:text-blue-400 flex justify-center">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={item.quantidade_max_peca}
|
||||
value={editQuantidade}
|
||||
onChange={(e) => setEditQuantidade(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSalvarEdicao(item);
|
||||
if (e.key === 'Escape') handleCancelarEdicao();
|
||||
}}
|
||||
className="h-7 w-20 text-center text-xs px-1"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
item.quantidade_expedida
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right text-gray-300">
|
||||
<div className="text-right text-muted-foreground">
|
||||
{item.peso_unitario.toFixed(2)} kg
|
||||
</div>
|
||||
<div className="text-right font-medium text-white">
|
||||
<div className="text-right font-semibold text-foreground">
|
||||
{item.peso_total.toFixed(2)} kg
|
||||
</div>
|
||||
{!isTablet && (
|
||||
<div className="flex justify-center">
|
||||
<Badge className={`text-xs ${getPrioridadeColor(item.prioridade)}`}>
|
||||
<Badge variant="outline" className={`text-xs ${getPrioridadeColor(item.prioridade)}`}>
|
||||
{item.prioridade}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center justify-center gap-2 ${isTablet ? 'col-span-2' : 'col-span-2'}`}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditarItem(item)}
|
||||
className="h-7 w-7 p-0 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoverItem(item.id)}
|
||||
className="h-7 w-7 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={savingEdit}
|
||||
onClick={() => handleSalvarEdicao(item)}
|
||||
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
|
||||
title="Salvar quantidade"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={savingEdit}
|
||||
onClick={handleCancelarEdicao}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:bg-muted"
|
||||
title="Cancelar edição"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditarItem(item)}
|
||||
className="h-7 w-7 p-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-500/15"
|
||||
title="Editar quantidade"
|
||||
>
|
||||
<Edit2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoverItem(item.id)}
|
||||
className="h-7 w-7 p-0 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-500/15"
|
||||
title="Remover item"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -315,11 +470,11 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`flex justify-end pt-3 border-t border-slate-700 shrink-0 ${isMobile ? 'px-1' : ''}`}>
|
||||
<div className={`flex justify-end pt-3 border-t border-border shrink-0 ${isMobile ? 'px-1' : ''}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className={`border-slate-600 text-gray-300 hover:bg-slate-800 ${isMobile ? 'h-10 px-4 text-sm' : ''}`}
|
||||
className={`font-medium text-foreground border-border hover:bg-accent hover:text-accent-foreground ${isMobile ? 'h-10 px-4 text-sm' : 'px-6'}`}
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
|
||||
@@ -10,8 +10,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Save, X, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
|
||||
import { toast } from 'sonner';
|
||||
import { validarRomaneioParaEntrega } from '@/utils/validacaoRomaneio';
|
||||
import { PendenciasProducaoModal, ItemPendenciaProducao } from './PendenciasProducaoModal';
|
||||
import { ConfirmacaoEntregaRomaneioModal } from './ConfirmacaoEntregaRomaneioModal';
|
||||
|
||||
interface RomaneioFormProps {
|
||||
romaneio?: RomaneioExpedicao;
|
||||
@@ -118,33 +117,22 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
|
||||
return Array.from(new Set(list)).filter(Boolean);
|
||||
}, [ofNumbers, romaneio?.of_number, selectedOF, formData.of_number]);
|
||||
|
||||
// Estados para controle de validação de entrega
|
||||
const [validandoEntrega, setValidandoEntrega] = useState(false);
|
||||
const [modalPendenciasAberto, setModalPendenciasAberto] = useState(false);
|
||||
const [pendenciasEncontradas, setPendenciasEncontradas] = useState<ItemPendenciaProducao[]>([]);
|
||||
const [mensagemBloqueio, setMensagemBloqueio] = useState<string | undefined>(undefined);
|
||||
// Estados para controle de validação e confirmação de entrega
|
||||
const [modalConfirmacaoAberto, setModalConfirmacaoAberto] = useState(false);
|
||||
|
||||
const handleStatusChange = async (novoStatus: any) => {
|
||||
const handleStatusChange = (novoStatus: any) => {
|
||||
if (novoStatus === 'Entregue') {
|
||||
if (!romaneio?.id) {
|
||||
toast.warning('Novo romaneio deve ser criado como "Em planejamento"', {
|
||||
description: 'Cadastre o romaneio primeiro, adicione as peças e confirme os apontamentos de produção antes de marcá-lo como entregue.'
|
||||
description: 'Cadastre o romaneio primeiro, adicione as peças e confirme os apontamentos antes de marcá-lo como entregue.'
|
||||
});
|
||||
setFormData(prev => ({ ...prev, status: 'Em planejamento' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidandoEntrega(true);
|
||||
const resultado = await validarRomaneioParaEntrega(romaneio.id, formData.of_number || romaneio.of_number);
|
||||
setValidandoEntrega(false);
|
||||
|
||||
if (!resultado.valido) {
|
||||
setPendenciasEncontradas(resultado.pendencias || []);
|
||||
setMensagemBloqueio(resultado.mensagem);
|
||||
setModalPendenciasAberto(true);
|
||||
// Não altera o status para Entregue, mantém o anterior
|
||||
return;
|
||||
}
|
||||
// Abre o modal inteligente de confirmação de entrega
|
||||
setModalConfirmacaoAberto(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({ ...prev, status: novoStatus }));
|
||||
@@ -153,24 +141,16 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (formData.status === 'Entregue') {
|
||||
if (formData.status === 'Entregue' && romaneio?.status !== 'Entregue') {
|
||||
if (!romaneio?.id) {
|
||||
toast.error('Não é possível criar um novo romaneio diretamente como "Entregue".');
|
||||
setFormData(prev => ({ ...prev, status: 'Em planejamento' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setValidandoEntrega(true);
|
||||
const resultado = await validarRomaneioParaEntrega(romaneio.id, formData.of_number || romaneio.of_number);
|
||||
setValidandoEntrega(false);
|
||||
|
||||
if (!resultado.valido) {
|
||||
setPendenciasEncontradas(resultado.pendencias || []);
|
||||
setMensagemBloqueio(resultado.mensagem);
|
||||
setModalPendenciasAberto(true);
|
||||
toast.error('Não é possível definir como "Entregue": existem pendências na produção.');
|
||||
return;
|
||||
}
|
||||
// Se tentar salvar como Entregue sem ter passado pela confirmação, abrir modal
|
||||
setModalConfirmacaoAberto(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('💾 Salvando romaneio com dados:', formData);
|
||||
@@ -322,7 +302,6 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={handleStatusChange}
|
||||
disabled={validandoEntrega}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -339,10 +318,10 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
|
||||
</div>
|
||||
|
||||
{/* Badge de Alerta */}
|
||||
<Alert className="border-orange-500 bg-orange-50 dark:bg-orange-950/20 dark:border-orange-400 mt-4">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||
<AlertDescription className="text-orange-800 dark:text-orange-200 font-medium">
|
||||
QUANDO O STATUS PASSAR PARA "ENTREGUE" AS PEÇAS SERÃO APONTADAS AUTOMATICAMENTE NO PROCESSO DE EXPEDIÇÃO (DESDE QUE TODOS OS PROCESSOS ANTERIORES ESTEJAM CONCLUÍDOS)
|
||||
<Alert className="border-blue-500 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-400 mt-4">
|
||||
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<AlertDescription className="text-blue-900 dark:text-blue-200 font-medium">
|
||||
AO PASSAR O STATUS PARA "ENTREGUE", VOCÊ ESCOLHE A DATA REAL DA ENTREGA E PODE AUTORIZAR O APONTAMENTO AUTOMÁTICO (INCLUSIVE EM CASCATA CASO HAJA PROCESSOS ANTERIORES PENDENTES).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -491,13 +470,8 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button type="submit" disabled={loading || validandoEntrega || !formData.of_number}>
|
||||
{validandoEntrega ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Validando processos...
|
||||
</>
|
||||
) : loading ? (
|
||||
<Button type="submit" disabled={loading || !formData.of_number}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Salvando...
|
||||
@@ -515,19 +489,28 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal de bloqueio/alerta caso existam peças pendentes */}
|
||||
<PendenciasProducaoModal
|
||||
isOpen={modalPendenciasAberto}
|
||||
{/* Modal de confirmação inteligente com Data Real e Cascata */}
|
||||
<ConfirmacaoEntregaRomaneioModal
|
||||
isOpen={modalConfirmacaoAberto}
|
||||
onClose={() => {
|
||||
setModalPendenciasAberto(false);
|
||||
setModalConfirmacaoAberto(false);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
status: (romaneio?.status as any) || 'Em planejamento'
|
||||
}));
|
||||
}}
|
||||
pendencias={pendenciasEncontradas}
|
||||
numeroRomaneio={romaneio?.numero_romaneio}
|
||||
mensagemSemItens={mensagemBloqueio}
|
||||
romaneio={romaneio ? {
|
||||
id: romaneio.id,
|
||||
numero_romaneio: romaneio.numero_romaneio,
|
||||
of_number: formData.of_number || romaneio.of_number,
|
||||
data_prevista_entrega: formData.data_prevista_entrega || romaneio.data_prevista_entrega,
|
||||
data_romaneio: formData.data_romaneio || romaneio.data_romaneio,
|
||||
status: formData.status
|
||||
} : null}
|
||||
onSucesso={() => {
|
||||
setModalConfirmacaoAberto(false);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,11 @@ import React, { useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Edit, Eye, Trash2, Printer } from 'lucide-react';
|
||||
import { Edit, Eye, Trash2, Printer, Truck } from 'lucide-react';
|
||||
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
|
||||
import { formatBrazilianDateFromString } from '@/utils/dateTimeUtils';
|
||||
import { RomaneioReportModal } from './RomaneioReportModal';
|
||||
import { ConfirmacaoEntregaRomaneioModal } from './ConfirmacaoEntregaRomaneioModal';
|
||||
|
||||
|
||||
interface RomaneioTableProps {
|
||||
@@ -28,6 +29,7 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
|
||||
}) => {
|
||||
const [selectedRomaneioForReport, setSelectedRomaneioForReport] = useState<RomaneioExpedicao | null>(null);
|
||||
const [showReportModal, setShowReportModal] = useState(false);
|
||||
const [romaneioParaConfirmacao, setRomaneioParaConfirmacao] = useState<RomaneioExpedicao | null>(null);
|
||||
|
||||
const handlePrintReport = (romaneio: RomaneioExpedicao) => {
|
||||
setSelectedRomaneioForReport(romaneio);
|
||||
@@ -126,7 +128,16 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
|
||||
{romaneio.peso_total_romaneio?.toFixed(2) || '0.00'} kg
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRomaneioParaConfirmacao(romaneio)}
|
||||
title="Confirmar Entrega / Apontamentos"
|
||||
className="h-8 w-8 p-0 text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/40"
|
||||
>
|
||||
<Truck className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -175,6 +186,16 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
|
||||
romaneio={selectedRomaneioForReport}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal de Confirmação de Entrega com Data Real e Cascata */}
|
||||
{romaneioParaConfirmacao && (
|
||||
<ConfirmacaoEntregaRomaneioModal
|
||||
isOpen={!!romaneioParaConfirmacao}
|
||||
onClose={() => setRomaneioParaConfirmacao(null)}
|
||||
romaneio={romaneioParaConfirmacao}
|
||||
onSucesso={() => setRomaneioParaConfirmacao(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -224,7 +223,7 @@ export const ImportarPecasDiffModal: React.FC<ImportarPecasDiffModalProps> = ({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl max-h-[92vh] flex flex-col p-0 overflow-hidden bg-background border-border">
|
||||
<DialogContent className="max-w-6xl h-[90vh] max-h-[92vh] flex flex-col p-0 overflow-hidden bg-background border-border">
|
||||
{/* Cabeçalho */}
|
||||
<DialogHeader className="p-4 border-b border-border bg-card flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -367,7 +366,7 @@ export const ImportarPecasDiffModal: React.FC<ImportarPecasDiffModalProps> = ({
|
||||
</DialogHeader>
|
||||
|
||||
{/* Barra de Filtros e Ferramentas */}
|
||||
<div className="p-3 border-b border-border bg-muted/20 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<div className="p-3 border-b border-border bg-muted/20 flex flex-wrap items-center justify-between gap-2 text-xs flex-shrink-0">
|
||||
{/* Input de Busca */}
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -411,18 +410,18 @@ export const ImportarPecasDiffModal: React.FC<ImportarPecasDiffModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabela de Conciliação e Diff */}
|
||||
<ScrollArea className="flex-1">
|
||||
{/* Tabela de Conciliação e Diff com Scroll Vertical e Cabeçalho Sticky */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-auto w-full">
|
||||
<div className="p-4">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground font-semibold bg-muted/40">
|
||||
<th className="p-2 w-10 text-center">Sobrepor</th>
|
||||
<th className="p-2 w-28">Peça / Fase</th>
|
||||
<th className="p-2 w-44">Perfil / Bitola</th>
|
||||
<th className="p-2 w-40">Status de Produção</th>
|
||||
<th className="p-2">Divergências Detectadas (Diff)</th>
|
||||
<th className="p-2 w-28 text-center">Lembrete Fabril</th>
|
||||
<thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm shadow-sm">
|
||||
<tr className="border-b border-border text-muted-foreground font-semibold">
|
||||
<th className="p-2 w-10 text-center bg-muted/95">Sobrepor</th>
|
||||
<th className="p-2 w-28 bg-muted/95">Peça / Fase</th>
|
||||
<th className="p-2 w-44 bg-muted/95">Perfil / Bitola</th>
|
||||
<th className="p-2 w-40 bg-muted/95">Status de Produção</th>
|
||||
<th className="p-2 bg-muted/95">Divergências Detectadas (Diff)</th>
|
||||
<th className="p-2 w-28 text-center bg-muted/95">Lembrete Fabril</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@@ -623,7 +622,7 @@ export const ImportarPecasDiffModal: React.FC<ImportarPecasDiffModalProps> = ({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Rodapé da Janela com Decisão e Ações */}
|
||||
<DialogFooter className="p-3 border-t border-border bg-card flex flex-row items-center justify-between flex-shrink-0">
|
||||
|
||||
@@ -118,14 +118,13 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
|
||||
const ofNumber = apontamento.of_number;
|
||||
const processoNome = apontamento.processo?.nome || 'Processo Desconhecido';
|
||||
|
||||
// Calcular peso baseado no tipo de apontamento
|
||||
let pesoUnitario = 0;
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
|
||||
pesoUnitario = Number(apontamento.componente.peso_unitario);
|
||||
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
|
||||
pesoUnitario = Number(apontamento.peca.peso_unitario);
|
||||
// Blindagem contra dupla contagem: apenas peças mestres computam peso nos gráficos de produção.
|
||||
// Componentes são rastreados para controle de kit/corte, mas não somam peso na balança global.
|
||||
if (apontamento.tipo_apontamento !== 'peca') {
|
||||
return;
|
||||
}
|
||||
|
||||
const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
|
||||
let pesoTotal = pesoUnitario * Number(apontamento.quantidade_produzida);
|
||||
|
||||
// Para projeção, calcular média diária e multiplicar por 7
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from 'sonner';
|
||||
import { sincronizarPecaKitNoCorte, sincronizarTodosKitsOFCorte } from '@/utils/sincronizacaoKitsCorte';
|
||||
|
||||
// Função utilitária para retry de requisições
|
||||
const retryRequest = async <T,>(
|
||||
@@ -378,6 +379,36 @@ interface InsertApontamentoData {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// REGRA DO KIT COMPLETO:
|
||||
// Se apontou componente no processo de Corte, verificar se todos os componentes da peça pai completaram o kit.
|
||||
// Se completou, cria automaticamente o apontamento da Peça Principal no Corte (tipo_apontamento = 'peca'),
|
||||
// computando o peso integral da peça mestre no Corte e liberando-a para a Solda!
|
||||
if (insertData.tipo_apontamento === 'componente' && insertData.peca_id) {
|
||||
try {
|
||||
const procAtual = processos.find(p => p.id === insertData.processo_id);
|
||||
const isProcessoCorte = procAtual
|
||||
? (procAtual.nome.toLowerCase().includes('corte') || procAtual.nome.toLowerCase().includes('prepar') || procAtual.ordem === 1)
|
||||
: true;
|
||||
|
||||
if (isProcessoCorte) {
|
||||
const resSync = await sincronizarPecaKitNoCorte({
|
||||
ofNumber: insertData.of_number,
|
||||
pecaId: insertData.peca_id,
|
||||
processoCorteId: insertData.processo_id,
|
||||
userId: user.id,
|
||||
usuarioNome: user.name || user.username || user.email?.split('@')[0] || 'Operador',
|
||||
dataApontamento: insertData.data_apontamento
|
||||
});
|
||||
|
||||
if (resSync && resSync.delta_liberado > 0) {
|
||||
toast.success(`Kit 100% cortado! +${resSync.delta_liberado} un da peça ${resSync.marca} liberada(s) para Solda (${resSync.kits_completos}/${resSync.quantidade_total}).`);
|
||||
}
|
||||
}
|
||||
} catch (kitError) {
|
||||
console.error('Erro ao verificar regra de kit completo:', kitError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Apontamento criado com sucesso, forçando atualização completa...');
|
||||
|
||||
// FORÇA ATUALIZAÇÃO COMPLETA DOS DADOS
|
||||
@@ -507,6 +538,38 @@ interface InsertApontamentoData {
|
||||
}
|
||||
}, [user, fetchApontamentos, fetchProcessos]);
|
||||
|
||||
const sincronizarKitsOF = useCallback(async (ofNumber: string, processoCorteId?: string) => {
|
||||
try {
|
||||
const procCorte = processoCorteId || processos.find(p => {
|
||||
const n = (p.nome || '').toLowerCase();
|
||||
return n.includes('corte') || n.includes('prepar') || p.ordem === 1;
|
||||
})?.id;
|
||||
|
||||
if (!procCorte) {
|
||||
console.warn('Processo de corte não encontrado para sincronização de kits.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const resultados = await sincronizarTodosKitsOFCorte({
|
||||
ofNumber,
|
||||
processoCorteId: procCorte,
|
||||
userId: user?.id,
|
||||
usuarioNome: user?.name || user?.username || user?.email?.split('@')[0] || 'Operador'
|
||||
});
|
||||
|
||||
const totalLiberadas = resultados.reduce((sum, r) => sum + r.delta_liberado, 0);
|
||||
if (totalLiberadas > 0) {
|
||||
toast.success(`Sincronização: +${totalLiberadas} kit(s) completos liberados para Solda!`);
|
||||
await fetchApontamentos();
|
||||
}
|
||||
|
||||
return resultados;
|
||||
} catch (e) {
|
||||
console.error('Erro ao sincronizar kits da OF:', e);
|
||||
return [];
|
||||
}
|
||||
}, [processos, user, fetchApontamentos]);
|
||||
|
||||
return {
|
||||
apontamentos,
|
||||
processos,
|
||||
@@ -516,6 +579,7 @@ interface InsertApontamentoData {
|
||||
atualizarProcesso,
|
||||
exportarModeloCSV,
|
||||
buscarQuantidadeProcessada,
|
||||
sincronizarKitsOF,
|
||||
refetch
|
||||
};
|
||||
};
|
||||
|
||||
+22
-5
@@ -24,16 +24,33 @@ import {
|
||||
// Sincroniza user Logto com profiles Supabase e retorna o UUID real do Supabase
|
||||
async function syncUserToProfile(user: LogtoUser): Promise<string> {
|
||||
if (!user?.sub) return user.sub;
|
||||
const email = user.email || `${user.username || 'user'}@logto.local`;
|
||||
const email = (user.email || `${user.username || 'user'}@logto.local`).trim();
|
||||
|
||||
try {
|
||||
// 1. Tenta buscar o perfil existente pelo email
|
||||
const { data: existing } = await supabase
|
||||
// 1. Tenta buscar o perfil existente pelo email (case-insensitive)
|
||||
let { data: existing, error: selectError } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('email', email)
|
||||
.ilike('email', email)
|
||||
.maybeSingle();
|
||||
|
||||
// Em caso de falha transitória de rede, faz 1 retry rápido antes de desistir
|
||||
if (selectError) {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const retry = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.ilike('email', email)
|
||||
.maybeSingle();
|
||||
existing = retry.data;
|
||||
selectError = retry.error;
|
||||
}
|
||||
|
||||
if (selectError) {
|
||||
console.warn('Erro de rede ao consultar profile no Supabase:', selectError);
|
||||
return user.sub;
|
||||
}
|
||||
|
||||
let profileId = existing?.id;
|
||||
|
||||
// 2. Se existe, atualizamos o nome e retornamos o UUID
|
||||
@@ -47,7 +64,7 @@ async function syncUserToProfile(user: LogtoUser): Promise<string> {
|
||||
return profileId;
|
||||
}
|
||||
|
||||
// 3. Se não existir, criamos um novo com um UUID gerado
|
||||
// 3. Se confirmadamente não existir, criamos um novo com um UUID gerado
|
||||
profileId = crypto.randomUUID();
|
||||
const { error: insertError } = await supabase.from('profiles').insert({
|
||||
id: profileId,
|
||||
|
||||
@@ -3,6 +3,14 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
||||
export interface ComponenteVinculoDetalhe {
|
||||
componente_id: string;
|
||||
peca_id: string;
|
||||
peca_marca: string;
|
||||
quantidade_por_peca: number;
|
||||
quantidade_necessaria: number;
|
||||
}
|
||||
|
||||
export interface ComponenteAgrupado {
|
||||
marca_componente: string;
|
||||
descricao: string;
|
||||
@@ -12,6 +20,7 @@ export interface ComponenteAgrupado {
|
||||
peca_pai_info: string;
|
||||
componente_ids: string[];
|
||||
peca_ids: string[];
|
||||
vinculos: ComponenteVinculoDetalhe[];
|
||||
}
|
||||
|
||||
// Cache local para evitar buscas repetitivas
|
||||
@@ -36,7 +45,7 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
|
||||
peca &&
|
||||
peca.of_number === debouncedOfNumber &&
|
||||
peca.etapa_fase === debouncedFase &&
|
||||
peca.tem_componentes === true // Apenas peças com componentes
|
||||
peca.tem_componentes !== false // Peças com componentes (ou não marcadas como S/M)
|
||||
);
|
||||
}, [pecasData, debouncedOfNumber, debouncedFase]);
|
||||
|
||||
@@ -101,22 +110,40 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
|
||||
|
||||
if (!pecaPai) return;
|
||||
|
||||
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
|
||||
const qtdTotalPeca = Number(pecaPai.quantidade) || 0;
|
||||
const qtdNecessaria = porPeca * qtdTotalPeca;
|
||||
|
||||
const vinculo: ComponenteVinculoDetalhe = {
|
||||
componente_id: comp.id,
|
||||
peca_id: comp.peca_id,
|
||||
peca_marca: pecaPai.marca,
|
||||
quantidade_por_peca: porPeca,
|
||||
quantidade_necessaria: qtdNecessaria,
|
||||
};
|
||||
|
||||
if (grupos.has(marca)) {
|
||||
const grupo = grupos.get(marca)!;
|
||||
grupo.quantidade_total += comp.quantidade_por_peca * (pecaPai.quantidade || 0);
|
||||
grupo.peca_pai_info += `, ${pecaPai.marca}`;
|
||||
grupo.quantidade_total += qtdNecessaria;
|
||||
if (!grupo.peca_pai_info.includes(pecaPai.marca)) {
|
||||
grupo.peca_pai_info += `, ${pecaPai.marca}`;
|
||||
}
|
||||
grupo.componente_ids.push(comp.id);
|
||||
grupo.peca_ids.push(comp.peca_id);
|
||||
if (!grupo.peca_ids.includes(comp.peca_id)) {
|
||||
grupo.peca_ids.push(comp.peca_id);
|
||||
}
|
||||
grupo.vinculos.push(vinculo);
|
||||
} else {
|
||||
grupos.set(marca, {
|
||||
marca_componente: marca,
|
||||
descricao: comp.descricao || '',
|
||||
perfil: comp.perfil || '',
|
||||
peso_unitario: comp.peso_unitario,
|
||||
quantidade_total: comp.quantidade_por_peca * (pecaPai.quantidade || 0),
|
||||
quantidade_total: qtdNecessaria,
|
||||
peca_pai_info: pecaPai.marca,
|
||||
componente_ids: [comp.id],
|
||||
peca_ids: [comp.peca_id]
|
||||
peca_ids: [comp.peca_id],
|
||||
vinculos: [vinculo],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -133,6 +160,8 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
|
||||
|
||||
return {
|
||||
componentesAgrupados,
|
||||
componentesRaw: componentes || [],
|
||||
loading: loading || componentesLoading
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -171,16 +171,11 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
const processoCorteId = processoCorte.data?.id;
|
||||
const pecasInfoMap = new Map((pecasData || []).map(p => [p.id, p]));
|
||||
|
||||
// 1. Somar peças com componentes apontadas na Solda
|
||||
// 1. Somar peças com componentes apontadas na Solda (apenas tipo peca)
|
||||
if (processoSoldaId) {
|
||||
const apontamentosSolda = apontamentosData.filter(a => a.processo_id === processoSoldaId);
|
||||
const apontamentosSolda = apontamentosData.filter(a => a.processo_id === processoSoldaId && a.tipo_apontamento === 'peca');
|
||||
apontamentosSolda.forEach(apontamento => {
|
||||
let pesoUnitario = 0;
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
|
||||
pesoUnitario = Number(apontamento.componente.peso_unitario);
|
||||
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
|
||||
pesoUnitario = Number(apontamento.peca.peso_unitario);
|
||||
}
|
||||
const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
|
||||
const pesoApontamento = pesoUnitario * Number(apontamento.quantidade_produzida || 0);
|
||||
pesoTotalFabricado += pesoApontamento;
|
||||
});
|
||||
@@ -357,15 +352,10 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
|
||||
let pesoFabricadoProcesso = 0;
|
||||
apontamentosProcesso.forEach(a => {
|
||||
let pesoUnitario = 0;
|
||||
|
||||
if (a.tipo_apontamento === 'componente' && a.componente?.peso_unitario) {
|
||||
pesoUnitario = Number(a.componente.peso_unitario);
|
||||
} else if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
|
||||
pesoUnitario = Number(a.peca.peso_unitario);
|
||||
// Apenas apontamentos de peça mestre somam peso no processo para evitar dupla contagem com subcomponentes
|
||||
if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
|
||||
pesoFabricadoProcesso += Number(a.peca.peso_unitario) * Number(a.quantidade_produzida);
|
||||
}
|
||||
|
||||
pesoFabricadoProcesso += pesoUnitario * Number(a.quantidade_produzida);
|
||||
});
|
||||
|
||||
const isProcessoSolda = processo.nome.toLowerCase().includes('solda');
|
||||
@@ -507,17 +497,12 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
|
||||
return dataAptStr <= dataStr;
|
||||
});
|
||||
let realizado = 0;
|
||||
apontamentosAteData.forEach(a => {
|
||||
let pesoUnitario = 0;
|
||||
|
||||
if (a.tipo_apontamento === 'componente' && a.componente?.peso_unitario) {
|
||||
pesoUnitario = Number(a.componente.peso_unitario);
|
||||
} else if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
|
||||
pesoUnitario = Number(a.peca.peso_unitario);
|
||||
}
|
||||
|
||||
realizado += pesoUnitario * Number(a.quantidade_produzida);
|
||||
});
|
||||
apontamentosAteData.forEach(a => {
|
||||
// Apenas peças mestres computam peso realizado no gráfico
|
||||
if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
|
||||
realizado += Number(a.peca.peso_unitario) * Number(a.quantidade_produzida);
|
||||
}
|
||||
});
|
||||
|
||||
dadosGrafico.push({
|
||||
data: dataStr,
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
export interface ComponenteKitInfo {
|
||||
id: string;
|
||||
peca_id: string;
|
||||
marca_componente: string;
|
||||
descricao?: string | null;
|
||||
perfil?: string | null;
|
||||
peso_unitario: number;
|
||||
quantidade_por_peca: number;
|
||||
quantidade_total_necessaria: number;
|
||||
quantidade_cortada: number;
|
||||
kits_possiveis: number;
|
||||
percentual_cortado: number;
|
||||
falta_para_prox_kit: number;
|
||||
concluido: boolean;
|
||||
}
|
||||
|
||||
export interface PecaKitInfo {
|
||||
peca_id: string;
|
||||
marca: string;
|
||||
descricao?: string | null;
|
||||
perfil_principal?: string | null;
|
||||
quantidade_total: number;
|
||||
etapa_fase: string;
|
||||
tem_componentes: boolean;
|
||||
peso_unitario: number;
|
||||
peso_total_peca: number;
|
||||
peso_kits_liberados: number;
|
||||
componentes: ComponenteKitInfo[];
|
||||
kits_completos_cortados: number; // Quantidade de kits 100% montáveis liberados
|
||||
percentual_termico: number; // 0 a 100%
|
||||
status_termico: 'verde' | 'verde_parcial' | 'amarelo' | 'vermelho';
|
||||
componente_gargalo: ComponenteKitInfo | null;
|
||||
peso_total_componentes: number;
|
||||
peso_cortado_componentes: number;
|
||||
ja_apontado_solda: number;
|
||||
saldo_disponivel_solda: number;
|
||||
bloqueado_para_solda: boolean;
|
||||
motivo_bloqueio_solda: string | null;
|
||||
}
|
||||
|
||||
export interface ResumoKitsOF {
|
||||
totalPecas: number;
|
||||
totalKits100Porcento: number;
|
||||
totalKitsParciais: number;
|
||||
totalKitsNaoIniciados: number;
|
||||
pesoTotalOFKg: number;
|
||||
pesoCortadoTotalKg: number;
|
||||
peso100PorcentoKg: number;
|
||||
pesoUnidadesLiberadasKg: number;
|
||||
pesoParciaisKg: number;
|
||||
pesoNaoIniciadosKg: number;
|
||||
percentualGeralOF: number;
|
||||
totalUnidadesTotal: number;
|
||||
totalUnidadesLiberadasSolda: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Função pura para cálculo determinístico de Kits de Produção.
|
||||
* Recebe peças, componentes e apontamentos e retorna o mapa térmico de kits.
|
||||
*/
|
||||
export const calcularKitsOF = (
|
||||
pecas: Array<{
|
||||
id: string;
|
||||
marca: string;
|
||||
descricao?: string | null;
|
||||
perfil_principal?: string | null;
|
||||
quantidade: number;
|
||||
etapa_fase?: string | null;
|
||||
tem_componentes?: boolean | null;
|
||||
peso_unitario?: number | null;
|
||||
peso_total?: number | null;
|
||||
}>,
|
||||
componentes: Array<{
|
||||
id: string;
|
||||
peca_id: string;
|
||||
marca_componente: string;
|
||||
descricao?: string | null;
|
||||
perfil?: string | null;
|
||||
peso_unitario?: number | null;
|
||||
quantidade_por_peca?: number | null;
|
||||
}>,
|
||||
apontamentos: Array<{
|
||||
id?: string;
|
||||
peca_id?: string | null;
|
||||
componente_id?: string | null;
|
||||
processo_id: string;
|
||||
quantidade_produzida: number;
|
||||
tipo_apontamento?: string | null;
|
||||
}>,
|
||||
processoCorteId?: string,
|
||||
processoSoldaId?: string
|
||||
): Map<string, PecaKitInfo> => {
|
||||
const mapaKits = new Map<string, PecaKitInfo>();
|
||||
|
||||
// 1. Mapear componentes por peca_id
|
||||
const componentesPorPeca = new Map<string, typeof componentes>();
|
||||
componentes.forEach((c) => {
|
||||
if (!componentesPorPeca.has(c.peca_id)) {
|
||||
componentesPorPeca.set(c.peca_id, []);
|
||||
}
|
||||
componentesPorPeca.get(c.peca_id)!.push(c);
|
||||
});
|
||||
|
||||
// 2. Mapear apontamentos de corte por componente_id
|
||||
const cortadoPorComponente = new Map<string, number>();
|
||||
// Apontamentos de corte por peca_id (caso seja peça sem componentes ou apontamento direto)
|
||||
const cortadoPecaNoCorte = new Map<string, number>();
|
||||
// Apontamentos de solda por peca_id
|
||||
const apontadoNaSoldaPorPeca = new Map<string, number>();
|
||||
|
||||
apontamentos.forEach((ap) => {
|
||||
const qtd = Number(ap.quantidade_produzida || 0);
|
||||
|
||||
// Se for apontamento de componente no corte (ou se não especificou processoCorteId, computa todos de componente)
|
||||
if (ap.tipo_apontamento === 'componente' && ap.componente_id) {
|
||||
if (!processoCorteId || ap.processo_id === processoCorteId) {
|
||||
cortadoPorComponente.set(
|
||||
ap.componente_id,
|
||||
(cortadoPorComponente.get(ap.componente_id) || 0) + qtd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Se for apontamento de peça mestre no corte
|
||||
if (
|
||||
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
|
||||
!ap.componente_id &&
|
||||
ap.peca_id
|
||||
) {
|
||||
if (!processoCorteId || ap.processo_id === processoCorteId) {
|
||||
cortadoPecaNoCorte.set(
|
||||
ap.peca_id,
|
||||
(cortadoPecaNoCorte.get(ap.peca_id) || 0) + qtd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Se for apontamento de solda
|
||||
if (
|
||||
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
|
||||
!ap.componente_id &&
|
||||
ap.peca_id
|
||||
) {
|
||||
if (processoSoldaId && ap.processo_id === processoSoldaId) {
|
||||
apontadoNaSoldaPorPeca.set(
|
||||
ap.peca_id,
|
||||
(apontadoNaSoldaPorPeca.get(ap.peca_id) || 0) + qtd
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Processar cada peça
|
||||
pecas.forEach((peca) => {
|
||||
const compsRaw = componentesPorPeca.get(peca.id) || [];
|
||||
const qtdTotalPeca = Number(peca.quantidade) || 0;
|
||||
const fase = peca.etapa_fase || 'Geral';
|
||||
const temComps = peca.tem_componentes !== false && compsRaw.length > 0;
|
||||
const jaApontadoSolda = apontadoNaSoldaPorPeca.get(peca.id) || 0;
|
||||
|
||||
const pesoUnitPeca = Number(peca.peso_unitario) || (qtdTotalPeca > 0 && peca.peso_total ? Number(peca.peso_total) / qtdTotalPeca : 0);
|
||||
const pesoTotalPeca = Number(peca.peso_total) || (qtdTotalPeca * pesoUnitPeca);
|
||||
|
||||
// Cenário A: Peça sem componentes (monobloco / S/M)
|
||||
if (!temComps) {
|
||||
const qtdCortada = cortadoPecaNoCorte.get(peca.id) || 0;
|
||||
const kitsCompletos = Math.min(qtdTotalPeca, qtdCortada);
|
||||
const pct = qtdTotalPeca > 0 ? Math.min(100, Math.round((kitsCompletos / qtdTotalPeca) * 100)) : 0;
|
||||
const saldoSolda = Math.max(0, kitsCompletos - jaApontadoSolda);
|
||||
const pesoCortado = kitsCompletos * pesoUnitPeca;
|
||||
const pesoLiberado = kitsCompletos * pesoUnitPeca;
|
||||
|
||||
mapaKits.set(peca.id, {
|
||||
peca_id: peca.id,
|
||||
marca: peca.marca,
|
||||
descricao: peca.descricao,
|
||||
perfil_principal: peca.perfil_principal,
|
||||
quantidade_total: qtdTotalPeca,
|
||||
etapa_fase: fase,
|
||||
tem_componentes: false,
|
||||
peso_unitario: pesoUnitPeca,
|
||||
peso_total_peca: pesoTotalPeca,
|
||||
peso_kits_liberados: pesoLiberado,
|
||||
componentes: [],
|
||||
kits_completos_cortados: kitsCompletos,
|
||||
percentual_termico: pct,
|
||||
status_termico:
|
||||
kitsCompletos >= qtdTotalPeca
|
||||
? 'verde'
|
||||
: kitsCompletos > 0
|
||||
? 'verde_parcial'
|
||||
: pct >= 40
|
||||
? 'amarelo'
|
||||
: 'vermelho',
|
||||
componente_gargalo: null,
|
||||
peso_total_componentes: pesoTotalPeca,
|
||||
peso_cortado_componentes: pesoCortado,
|
||||
ja_apontado_solda: jaApontadoSolda,
|
||||
saldo_disponivel_solda: peca.tem_componentes === false ? 0 : saldoSolda,
|
||||
bloqueado_para_solda: peca.tem_componentes === false || saldoSolda <= 0,
|
||||
motivo_bloqueio_solda:
|
||||
peca.tem_componentes === false
|
||||
? 'Peça S/M (Pula Solda)'
|
||||
: saldoSolda <= 0 && kitsCompletos === 0
|
||||
? 'Aguardando corte da peça'
|
||||
: saldoSolda <= 0
|
||||
? `Todos os ${kitsCompletos} kits cortados já foram soldados`
|
||||
: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Cenário B: Peça com componentes
|
||||
let pesoTotalComps = 0;
|
||||
let pesoCortadoComps = 0;
|
||||
let menorKits = qtdTotalPeca;
|
||||
let gargaloComp: ComponenteKitInfo | null = null;
|
||||
let menorRazao = 999999;
|
||||
|
||||
const listaCompsInfo: ComponenteKitInfo[] = [];
|
||||
|
||||
compsRaw.forEach((comp) => {
|
||||
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
|
||||
const totalNecessario = qtdTotalPeca * porPeca;
|
||||
const cortado = cortadoPorComponente.get(comp.id) || 0;
|
||||
const pesoUnit = Number(comp.peso_unitario) || 0;
|
||||
|
||||
const kitsDesteComp = Math.floor(cortado / porPeca);
|
||||
if (kitsDesteComp < menorKits) {
|
||||
menorKits = kitsDesteComp;
|
||||
}
|
||||
|
||||
const pctComp = totalNecessario > 0 ? Math.min(100, Math.round((cortado / totalNecessario) * 100)) : 0;
|
||||
const pesoTotalComp = totalNecessario * pesoUnit;
|
||||
const pesoCortadoComp = Math.min(cortado, totalNecessario) * pesoUnit;
|
||||
|
||||
pesoTotalComps += pesoTotalComp;
|
||||
pesoCortadoComps += pesoCortadoComp;
|
||||
|
||||
const infoComp: ComponenteKitInfo = {
|
||||
id: comp.id,
|
||||
peca_id: comp.peca_id,
|
||||
marca_componente: comp.marca_componente,
|
||||
descricao: comp.descricao,
|
||||
perfil: comp.perfil,
|
||||
peso_unitario: pesoUnit,
|
||||
quantidade_por_peca: porPeca,
|
||||
quantidade_total_necessaria: totalNecessario,
|
||||
quantidade_cortada: cortado,
|
||||
kits_possiveis: kitsDesteComp,
|
||||
percentual_cortado: pctComp,
|
||||
falta_para_prox_kit: 0, // calculado logo abaixo
|
||||
concluido: cortado >= totalNecessario,
|
||||
};
|
||||
|
||||
listaCompsInfo.push(infoComp);
|
||||
});
|
||||
|
||||
const kitsCompletos = Math.min(qtdTotalPeca, Math.max(0, menorKits));
|
||||
|
||||
// Determinar o componente gargalo para o próximo kit
|
||||
listaCompsInfo.forEach((ci) => {
|
||||
const necessarioParaProxKit = Math.min(qtdTotalPeca, kitsCompletos + 1) * ci.quantidade_por_peca;
|
||||
ci.falta_para_prox_kit = Math.max(0, necessarioParaProxKit - ci.quantidade_cortada);
|
||||
|
||||
// Critério de gargalo: quem mais segura a formação de novos kits
|
||||
const razao = ci.kits_possiveis;
|
||||
if (razao < menorRazao && kitsCompletos < qtdTotalPeca) {
|
||||
menorRazao = razao;
|
||||
gargaloComp = ci;
|
||||
}
|
||||
});
|
||||
|
||||
// Percentual térmico de prontidão ponderado por peso (ou por qtd se peso=0)
|
||||
let percentualTermico = 0;
|
||||
if (pesoTotalComps > 0) {
|
||||
percentualTermico = Math.min(100, Math.round((pesoCortadoComps / pesoTotalComps) * 100));
|
||||
} else {
|
||||
const qtdTotalNecessaria = listaCompsInfo.reduce((s, c) => s + c.quantidade_total_necessaria, 0);
|
||||
const qtdTotalCortada = listaCompsInfo.reduce((s, c) => s + Math.min(c.quantidade_cortada, c.quantidade_total_necessaria), 0);
|
||||
percentualTermico = qtdTotalNecessaria > 0 ? Math.min(100, Math.round((qtdTotalCortada / qtdTotalNecessaria) * 100)) : 0;
|
||||
}
|
||||
|
||||
// Status térmico
|
||||
let statusTermico: 'verde' | 'verde_parcial' | 'amarelo' | 'vermelho' = 'vermelho';
|
||||
if (kitsCompletos >= qtdTotalPeca) {
|
||||
statusTermico = 'verde';
|
||||
} else if (kitsCompletos > 0) {
|
||||
statusTermico = 'verde_parcial';
|
||||
} else if (percentualTermico >= 40) {
|
||||
statusTermico = 'amarelo';
|
||||
} else {
|
||||
statusTermico = 'vermelho';
|
||||
}
|
||||
|
||||
const saldoSolda = Math.max(0, kitsCompletos - jaApontadoSolda);
|
||||
const bloqueadoSolda = saldoSolda <= 0;
|
||||
|
||||
let motivoBloqueioSolda: string | null = null;
|
||||
if (bloqueadoSolda) {
|
||||
if (kitsCompletos === 0) {
|
||||
const detGargalo = gargaloComp ? ` • Gargalo: ${gargaloComp.marca_componente} (faltam ${gargaloComp.falta_para_prox_kit} un)` : '';
|
||||
motivoBloqueioSolda = `Aguardando kits completos de corte (0 un liberadas)${detGargalo}`;
|
||||
} else if (jaApontadoSolda >= kitsCompletos) {
|
||||
motivoBloqueioSolda = `Todos os ${kitsCompletos} kits cortados já foram soldados (${jaApontadoSolda}/${qtdTotalPeca} un) • Aguardando novos kits cortados`;
|
||||
}
|
||||
}
|
||||
|
||||
const pesoUnitComps = pesoTotalComps > 0 && qtdTotalPeca > 0 ? pesoTotalComps / qtdTotalPeca : 0;
|
||||
const pesoUnitPecaFinal = Number(peca.peso_unitario) || pesoUnitComps;
|
||||
const pesoTotalPecaFinal = pesoTotalComps > 0 ? pesoTotalComps : (Number(peca.peso_total) || (qtdTotalPeca * pesoUnitPecaFinal));
|
||||
const pesoLiberadoFinal = kitsCompletos * pesoUnitPecaFinal;
|
||||
|
||||
mapaKits.set(peca.id, {
|
||||
peca_id: peca.id,
|
||||
marca: peca.marca,
|
||||
descricao: peca.descricao,
|
||||
perfil_principal: peca.perfil_principal,
|
||||
quantidade_total: qtdTotalPeca,
|
||||
etapa_fase: fase,
|
||||
tem_componentes: true,
|
||||
peso_unitario: pesoUnitPecaFinal,
|
||||
peso_total_peca: pesoTotalPecaFinal,
|
||||
peso_kits_liberados: pesoLiberadoFinal,
|
||||
componentes: listaCompsInfo,
|
||||
kits_completos_cortados: kitsCompletos,
|
||||
percentual_termico: percentualTermico,
|
||||
status_termico: statusTermico,
|
||||
componente_gargalo: gargaloComp,
|
||||
peso_total_componentes: pesoTotalPecaFinal,
|
||||
peso_cortado_componentes: pesoCortadoComps,
|
||||
ja_apontado_solda: jaApontadoSolda,
|
||||
saldo_disponivel_solda: saldoSolda,
|
||||
bloqueado_para_solda: bloqueadoSolda,
|
||||
motivo_bloqueio_solda: motivoBloqueioSolda,
|
||||
});
|
||||
});
|
||||
|
||||
return mapaKits;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook completo para carregar e gerenciar os Kits de uma OF e Fase.
|
||||
*/
|
||||
export const useKitsProducao = (ofNumber?: string, fase?: string) => {
|
||||
const { data, isLoading, refetch, error } = useQuery({
|
||||
queryKey: ['kits-producao', ofNumber, fase],
|
||||
enabled: Boolean(ofNumber),
|
||||
staleTime: 10000, // 10 segundos
|
||||
queryFn: async () => {
|
||||
if (!ofNumber) return { mapaKits: new Map<string, PecaKitInfo>(), resumo: null, lista: [] };
|
||||
|
||||
// 1. Buscar processos para identificar IDs de Corte e Solda
|
||||
const { data: procs } = await supabase
|
||||
.from('processos_fabricacao')
|
||||
.select('id, nome, ordem')
|
||||
.order('ordem');
|
||||
|
||||
const procCorte = (procs || []).find(
|
||||
(p) => p.nome.toLowerCase().includes('corte') || p.nome.toLowerCase().includes('prepar') || p.ordem === 1
|
||||
);
|
||||
const procSolda = (procs || []).find(
|
||||
(p) => p.nome.toLowerCase().includes('solda') || p.nome.toLowerCase().includes('montag') || p.ordem === 3
|
||||
);
|
||||
|
||||
// 2. Buscar peças da OF
|
||||
let pecasQuery = supabase
|
||||
.from('pecas')
|
||||
.select('id, marca, descricao, perfil_principal, quantidade, etapa_fase, tem_componentes, peso_unitario, peso_total')
|
||||
.eq('of_number', ofNumber);
|
||||
|
||||
if (fase && fase !== 'all' && fase !== 'Todas as fases' && fase !== 'todas') {
|
||||
pecasQuery = pecasQuery.eq('etapa_fase', fase);
|
||||
}
|
||||
|
||||
const { data: pecasData, error: pecasErr } = await pecasQuery;
|
||||
if (pecasErr) throw pecasErr;
|
||||
|
||||
const pecas = pecasData || [];
|
||||
const pecaIds = pecas.map((p) => p.id);
|
||||
|
||||
// 3. Buscar componentes das peças
|
||||
let componentes: Array<{
|
||||
id: string;
|
||||
peca_id: string;
|
||||
marca_componente: string;
|
||||
descricao?: string | null;
|
||||
perfil?: string | null;
|
||||
peso_unitario?: number | null;
|
||||
quantidade_por_peca?: number | null;
|
||||
}> = [];
|
||||
|
||||
if (pecaIds.length > 0) {
|
||||
for (let i = 0; i < pecaIds.length; i += 300) {
|
||||
const chunk = pecaIds.slice(i, i + 300);
|
||||
const { data: compsChunk, error: compsErr } = await supabase
|
||||
.from('componentes_peca')
|
||||
.select('id, peca_id, marca_componente, descricao, perfil, peso_unitario, quantidade_por_peca')
|
||||
.in('peca_id', chunk);
|
||||
|
||||
if (compsErr) throw compsErr;
|
||||
if (compsChunk) {
|
||||
componentes = [...componentes, ...compsChunk];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Buscar apontamentos da OF
|
||||
const { data: apontamentosData, error: aptsErr } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('id, peca_id, componente_id, processo_id, quantidade_produzida, tipo_apontamento')
|
||||
.eq('of_number', ofNumber);
|
||||
|
||||
if (aptsErr) throw aptsErr;
|
||||
|
||||
// 5. Calcular kits
|
||||
const mapaKits = calcularKitsOF(
|
||||
pecas,
|
||||
componentes,
|
||||
apontamentosData || [],
|
||||
procCorte?.id,
|
||||
procSolda?.id
|
||||
);
|
||||
|
||||
const lista = Array.from(mapaKits.values()).sort((a, b) =>
|
||||
a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' })
|
||||
);
|
||||
|
||||
// 6. Resumo geral
|
||||
let total100 = 0;
|
||||
let totalParciais = 0;
|
||||
let totalTravados = 0;
|
||||
let pesoTot = 0;
|
||||
let pesoCort = 0;
|
||||
let peso100 = 0;
|
||||
let pesoUnLiberadas = 0;
|
||||
let pesoParciais = 0;
|
||||
let pesoNaoIniciados = 0;
|
||||
let unTot = 0;
|
||||
let unLib = 0;
|
||||
|
||||
lista.forEach((item) => {
|
||||
unTot += item.quantidade_total;
|
||||
unLib += item.kits_completos_cortados;
|
||||
pesoTot += item.peso_total_peca;
|
||||
pesoCort += item.peso_cortado_componentes;
|
||||
pesoUnLiberadas += item.peso_kits_liberados;
|
||||
|
||||
if (item.status_termico === 'verde') {
|
||||
total100++;
|
||||
peso100 += item.peso_total_peca;
|
||||
} else if (item.status_termico === 'verde_parcial' || item.status_termico === 'amarelo') {
|
||||
totalParciais++;
|
||||
pesoParciais += item.peso_total_peca;
|
||||
} else {
|
||||
totalTravados++;
|
||||
pesoNaoIniciados += item.peso_total_peca;
|
||||
}
|
||||
});
|
||||
|
||||
const resumo: ResumoKitsOF = {
|
||||
totalPecas: lista.length,
|
||||
totalKits100Porcento: total100,
|
||||
totalKitsParciais: totalParciais,
|
||||
totalKitsNaoIniciados: totalTravados,
|
||||
pesoTotalOFKg: pesoTot,
|
||||
pesoCortadoTotalKg: pesoCort,
|
||||
peso100PorcentoKg: peso100,
|
||||
pesoUnidadesLiberadasKg: pesoUnLiberadas,
|
||||
pesoParciaisKg: pesoParciais,
|
||||
pesoNaoIniciadosKg: pesoNaoIniciados,
|
||||
percentualGeralOF: pesoTot > 0 ? Math.round((pesoCortadoTotalKg(pesoTot, pesoCort)) * 100) : 0,
|
||||
totalUnidadesTotal: unTot,
|
||||
totalUnidadesLiberadasSolda: unLib,
|
||||
};
|
||||
|
||||
return {
|
||||
mapaKits,
|
||||
resumo,
|
||||
lista,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
kitsPorPeca: data?.mapaKits || new Map<string, PecaKitInfo>(),
|
||||
listaKits: data?.lista || [],
|
||||
resumoKits: data?.resumo,
|
||||
loading: isLoading,
|
||||
refetch,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
function pesoCortadoTotalKg(total: number, cortado: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.min(1, cortado / total);
|
||||
}
|
||||
@@ -81,14 +81,11 @@ export const useProcessChartData = (ofNumber: string) => {
|
||||
|
||||
if (apontamentos && apontamentos.length > 0) {
|
||||
apontamentos.forEach(apontamento => {
|
||||
let pesoUnitario = 0;
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
|
||||
pesoUnitario = apontamento.componente.peso_unitario;
|
||||
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
|
||||
pesoUnitario = apontamento.peca.peso_unitario;
|
||||
}
|
||||
// Apenas peças mestres somam peso nas médias diárias e metas
|
||||
if (apontamento.tipo_apontamento !== 'peca') return;
|
||||
|
||||
const pesoTotal = pesoUnitario * apontamento.quantidade_produzida;
|
||||
const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
|
||||
const pesoTotal = pesoUnitario * Number(apontamento.quantidade_produzida);
|
||||
pesoTotalProcessado += pesoTotal;
|
||||
|
||||
if (apontamento.data_apontamento) {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ApontamentoDiario {
|
||||
descricao: string;
|
||||
processo: string;
|
||||
quantidade: number;
|
||||
tipo_apontamento: 'peca' | 'componente';
|
||||
peso_unitario: number;
|
||||
peso_total: number;
|
||||
data_apontamento: string;
|
||||
@@ -105,6 +106,7 @@ export const useRelatorioDiario = (selectedDate: string) => {
|
||||
descricao,
|
||||
processo: apt.processo?.nome || 'N/A',
|
||||
quantidade: apt.quantidade_produzida,
|
||||
tipo_apontamento: (apt.tipo_apontamento || 'peca') as 'peca' | 'componente',
|
||||
peso_unitario,
|
||||
peso_total,
|
||||
data_apontamento: apt.data_apontamento,
|
||||
@@ -139,11 +141,14 @@ export const useRelatorioDiario = (selectedDate: string) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDate]);
|
||||
|
||||
// Calcular resumo por processo
|
||||
// Calcular resumo por processo (apenas peças principais computam quantidade e peso no resumo da produção)
|
||||
const resumoProcessos: ResumoProcesso[] = React.useMemo(() => {
|
||||
const resumo = new Map<string, { quantidade: number; apontamentos: number; peso_total: number }>();
|
||||
|
||||
apontamentos.forEach(apt => {
|
||||
// Ignorar componentes no resumo global de produção para evitar peso e contagem duplicados
|
||||
if (apt.tipo_apontamento !== 'peca') return;
|
||||
|
||||
const processo = apt.processo;
|
||||
if (!resumo.has(processo)) {
|
||||
resumo.set(processo, { quantidade: 0, apontamentos: 0, peso_total: 0 });
|
||||
|
||||
+15
-11
@@ -128,12 +128,15 @@ export const usePecasPintura = (ofNumber?: string) => {
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
peca_id,
|
||||
componente_id,
|
||||
tipo_apontamento,
|
||||
quantidade_produzida,
|
||||
pecas (
|
||||
id,
|
||||
marca,
|
||||
etapa_fase,
|
||||
descricao,
|
||||
quantidade,
|
||||
peso_unitario,
|
||||
comprimento,
|
||||
of_number
|
||||
@@ -151,15 +154,16 @@ export const usePecasPintura = (ofNumber?: string) => {
|
||||
const { data: itensExpedidos, error: expedidosError } = await supabase
|
||||
.from('itens_romaneio_pecas')
|
||||
.select('peca_id, quantidade_expedida')
|
||||
.in('peca_id', apontamentosPintura?.map(a => a.peca_id) || []);
|
||||
.in('peca_id', apontamentosPintura?.map(a => a.peca_id).filter(Boolean) || []);
|
||||
|
||||
if (expedidosError) throw expedidosError;
|
||||
|
||||
// Calcular disponibilidade
|
||||
// Calcular disponibilidade (apenas peças principais, ignorando componentes)
|
||||
const pecasPintura: PecaPintura[] = [];
|
||||
const pecasMap = new Map();
|
||||
|
||||
apontamentosPintura?.forEach(apontamento => {
|
||||
if (apontamento.tipo_apontamento === 'componente' || apontamento.componente_id) return;
|
||||
const pecaId = apontamento.peca_id;
|
||||
const peca = apontamento.pecas;
|
||||
|
||||
@@ -173,6 +177,7 @@ export const usePecasPintura = (ofNumber?: string) => {
|
||||
marca: peca.marca,
|
||||
etapa_fase: peca.etapa_fase,
|
||||
descricao: peca.descricao,
|
||||
quantidade_total: peca.quantidade,
|
||||
peso_unitario: peca.peso_unitario,
|
||||
comprimento: peca.comprimento,
|
||||
quantidade_produzida_pintura: apontamento.quantidade_produzida,
|
||||
@@ -186,10 +191,14 @@ export const usePecasPintura = (ofNumber?: string) => {
|
||||
?.filter(item => item.peca_id === pecaId)
|
||||
.reduce((sum, item) => sum + item.quantidade_expedida, 0) || 0;
|
||||
|
||||
const qtdProdLimitada = peca.quantidade_total
|
||||
? Math.min(Number(peca.quantidade_total), peca.quantidade_produzida_pintura)
|
||||
: peca.quantidade_produzida_pintura;
|
||||
|
||||
pecasPintura.push({
|
||||
...peca,
|
||||
quantidade_ja_expedida: totalExpedido,
|
||||
quantidade_disponivel: peca.quantidade_produzida_pintura - totalExpedido
|
||||
quantidade_disponivel: qtdProdLimitada - totalExpedido
|
||||
});
|
||||
});
|
||||
|
||||
@@ -280,16 +289,11 @@ export const useAtualizarRomaneio = () => {
|
||||
console.log('📋 Status atual do romaneio:', romaneioAtual?.status);
|
||||
console.log('📋 Novo status a ser definido:', romaneio.status);
|
||||
|
||||
// Validação rigorosa: Não permitir passar para Entregue se houver pendências nos processos anteriores
|
||||
if (romaneio.status === 'Entregue') {
|
||||
// Se status for Entregue e não passou pelo modal de confirmação, validar amigavelmente
|
||||
if (romaneio.status === 'Entregue' && romaneioAtual?.status !== 'Entregue') {
|
||||
const validacao = await validarRomaneioParaEntrega(id, romaneio.of_number || romaneioAtual?.of_number);
|
||||
if (!validacao.valido) {
|
||||
const msg = validacao.mensagem || (
|
||||
validacao.pendencias && validacao.pendencias.length > 0
|
||||
? `Não é possível marcar como "Entregue": existem ${validacao.pendencias.length} peça(s) com apontamentos pendentes nos processos anteriores.`
|
||||
: 'Não é possível marcar como "Entregue": pendências encontradas.'
|
||||
);
|
||||
throw new Error(msg);
|
||||
console.warn('⚠️ Romaneio possui pendências fabris ao passar para Entregue:', validacao.pendencias?.length);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ export const useUserFunction = () => {
|
||||
queryFn: async () => {
|
||||
if (!user?.id) return null;
|
||||
|
||||
const { data, error } = await supabase
|
||||
const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id || '');
|
||||
let query = supabase
|
||||
.from('profiles')
|
||||
.select(`
|
||||
function_id,
|
||||
@@ -19,9 +20,17 @@ export const useUserFunction = () => {
|
||||
name,
|
||||
description
|
||||
)
|
||||
`)
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
`);
|
||||
|
||||
if (isValidUuid) {
|
||||
query = query.eq('id', user.id);
|
||||
} else if (user.email) {
|
||||
query = query.ilike('email', user.email.trim());
|
||||
} else {
|
||||
query = query.eq('id', user.id);
|
||||
}
|
||||
|
||||
const { data, error } = await query.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao buscar função do usuário:', error);
|
||||
|
||||
@@ -24,26 +24,56 @@ export function useUserPermissions() {
|
||||
|
||||
// Load user permissions
|
||||
const loadUserPermissions = useCallback(async () => {
|
||||
if (!user?.id) {
|
||||
if (!user?.id && !user?.email) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🔄 Loading permissions for user: ${user.email}`);
|
||||
const isUuid = Boolean(user.id && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id));
|
||||
|
||||
// Load functional permissions from user privilege
|
||||
const { data: profile } = await supabase
|
||||
// Load functional permissions from user privilege (by UUID or fallback by email)
|
||||
let profileQuery = supabase
|
||||
.from('profiles')
|
||||
.select(`
|
||||
id,
|
||||
privilege_id,
|
||||
privileges!inner (
|
||||
name,
|
||||
permissions
|
||||
)
|
||||
`)
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
`);
|
||||
|
||||
if (isUuid) {
|
||||
profileQuery = profileQuery.eq('id', user.id);
|
||||
} else if (user.email) {
|
||||
profileQuery = profileQuery.ilike('email', user.email.trim());
|
||||
} else {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let { data: profile } = await profileQuery.maybeSingle();
|
||||
|
||||
// Se buscou por UUID e não achou, tenta fallback por email
|
||||
if (!profile && isUuid && user.email) {
|
||||
const retryByEmail = await supabase
|
||||
.from('profiles')
|
||||
.select(`
|
||||
id,
|
||||
privilege_id,
|
||||
privileges!inner (
|
||||
name,
|
||||
permissions
|
||||
)
|
||||
`)
|
||||
.ilike('email', user.email.trim())
|
||||
.maybeSingle();
|
||||
profile = retryByEmail.data;
|
||||
}
|
||||
|
||||
const resolvedUserId = profile?.id || (isUuid ? user.id : null);
|
||||
|
||||
if (profile?.privileges?.permissions) {
|
||||
// Safe type conversion with validation
|
||||
@@ -61,18 +91,20 @@ export function useUserPermissions() {
|
||||
}
|
||||
|
||||
// Load specific resource permissions
|
||||
const { data: resourcePerms } = await supabase
|
||||
.from('user_interface_permissions')
|
||||
.select('resource_key, permission')
|
||||
.eq('user_id', user.id);
|
||||
if (resolvedUserId) {
|
||||
const { data: resourcePerms } = await supabase
|
||||
.from('user_interface_permissions')
|
||||
.select('resource_key, permission')
|
||||
.eq('user_id', resolvedUserId);
|
||||
|
||||
if (resourcePerms) {
|
||||
const permsMap = resourcePerms.reduce((acc, perm) => {
|
||||
acc[perm.resource_key] = perm.permission as PermissionLevel;
|
||||
return acc;
|
||||
}, {} as Record<string, PermissionLevel>);
|
||||
setResourcePermissions(permsMap);
|
||||
console.log('✅ Resource permissions loaded:', permsMap);
|
||||
if (resourcePerms) {
|
||||
const permsMap = resourcePerms.reduce((acc, perm) => {
|
||||
acc[perm.resource_key] = perm.permission as PermissionLevel;
|
||||
return acc;
|
||||
}, {} as Record<string, PermissionLevel>);
|
||||
setResourcePermissions(permsMap);
|
||||
console.log('✅ Resource permissions loaded:', permsMap);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -80,7 +112,7 @@ export function useUserPermissions() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.id]);
|
||||
}, [user?.id, user?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUserPermissions();
|
||||
|
||||
@@ -30,11 +30,20 @@ export function useUserProfile() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id || '');
|
||||
let query = supabase
|
||||
.from('profiles')
|
||||
.select('full_name, email, profile_image_url, status')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
.select('full_name, email, profile_image_url, status');
|
||||
|
||||
if (isValidUuid) {
|
||||
query = query.eq('id', user.id);
|
||||
} else if (user.email) {
|
||||
query = query.ilike('email', user.email.trim());
|
||||
} else {
|
||||
query = query.eq('id', user.id);
|
||||
}
|
||||
|
||||
const { data, error } = await query.maybeSingle();
|
||||
|
||||
if (!error && data) {
|
||||
setProfile(data);
|
||||
|
||||
@@ -119,6 +119,15 @@ export const useValidacaoSequencialProcessos = () => {
|
||||
return { valido: true };
|
||||
}
|
||||
|
||||
// Componentes só podem ser processados no primeiro processo (Corte / Preparação)
|
||||
const itemEComponente = historico.some(h => h.marca_item === marcaItem && h.tipo_item === 'componente');
|
||||
if (itemEComponente) {
|
||||
return {
|
||||
valido: false,
|
||||
erro: `Componente ${marcaItem} não pode ser apontado após a etapa de Corte / Preparação.`
|
||||
};
|
||||
}
|
||||
|
||||
// Buscar histórico do item específico
|
||||
const historicoItem = historico.filter(h => h.marca_item === marcaItem);
|
||||
|
||||
@@ -238,41 +247,14 @@ export const useValidacaoSequencialProcessos = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Processar componentes
|
||||
for (const componente of componentes) {
|
||||
const marcaComp = String(componente.marca_componente || '');
|
||||
const qtdComp = Number(componente.quantidade_total || 0);
|
||||
|
||||
if (ordemAtual === 1) {
|
||||
// Primeiro processo: todos os componentes estão disponíveis
|
||||
// Processar componentes: apenas disponíveis no primeiro processo (Corte / Preparação)
|
||||
if (ordemAtual === 1) {
|
||||
for (const componente of componentes) {
|
||||
const qtdComp = Number(componente.quantidade_total || 0);
|
||||
componentesDisponiveis.push({
|
||||
...componente,
|
||||
quantidade_disponivel: qtdComp
|
||||
});
|
||||
} else {
|
||||
// Calcular quantidade disponível baseada no processo anterior
|
||||
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
|
||||
marcaComp,
|
||||
ordemAtual - 1,
|
||||
ofNumber,
|
||||
'componente'
|
||||
);
|
||||
|
||||
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
|
||||
marcaComp,
|
||||
processoId,
|
||||
ofNumber,
|
||||
'componente'
|
||||
);
|
||||
|
||||
const quantidadeDisponivel = quantidadeProcessadaAnterior - quantidadeJaProcessadaAtual;
|
||||
|
||||
if (quantidadeDisponivel > 0) {
|
||||
componentesDisponiveis.push({
|
||||
...componente,
|
||||
quantidade_disponivel: quantidadeDisponivel
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ClipboardList, BarChart3, Settings } from 'lucide-react';
|
||||
import { ClipboardList, BarChart3, Settings, Flame } from 'lucide-react';
|
||||
import { ApontamentoForm } from '@/components/apontamento/ApontamentoForm';
|
||||
import { ApontamentosListOtimizado } from '@/components/apontamento/ApontamentosListOtimizado';
|
||||
import { ProcessosList } from '@/components/apontamento/ProcessosList';
|
||||
import { MapaTermicoKitsView } from '@/components/apontamento/kits/MapaTermicoKitsView';
|
||||
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
||||
@@ -34,7 +35,7 @@ const ApontamentoProducao = () => {
|
||||
<TabsList className={`w-full bg-muted border-border ${
|
||||
isMobile
|
||||
? 'grid grid-cols-1 gap-1 h-auto p-1'
|
||||
: 'grid grid-cols-3 h-10'
|
||||
: 'grid grid-cols-4 h-10'
|
||||
}`}>
|
||||
<TabsTrigger
|
||||
value="apontamento"
|
||||
@@ -45,6 +46,15 @@ const ApontamentoProducao = () => {
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
Novo Apontamento
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="mapa-kits"
|
||||
className={`flex items-center gap-2 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground ${
|
||||
isMobile ? 'w-full justify-start p-3 text-sm' : ''
|
||||
}`}
|
||||
>
|
||||
<Flame className="h-4 w-4 text-orange-500 fill-orange-500 animate-pulse" />
|
||||
Mapa de Kits
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="historico"
|
||||
className={`flex items-center gap-2 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground ${
|
||||
@@ -89,6 +99,23 @@ const ApontamentoProducao = () => {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mapa-kits" className="space-y-4 sm:space-y-6 mt-4">
|
||||
<Card className="bg-card border-border shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-border/60">
|
||||
<CardTitle className="text-card-foreground flex items-center gap-2 text-lg sm:text-xl font-bold">
|
||||
<Flame className="h-5 w-5 text-orange-500 fill-orange-500 animate-pulse" />
|
||||
<span>Mapa Térmico de Kits de Montagem</span>
|
||||
</CardTitle>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Visão integrada do balanceamento do corte de componentes e liberação de kits completos para montagem/solda
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<MapaTermicoKitsView />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="historico" className="space-y-4 sm:space-y-6 mt-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="pb-4">
|
||||
|
||||
@@ -110,11 +110,11 @@ export default function Visualizador3D() {
|
||||
.select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material')
|
||||
.eq('of_number', selectedOF);
|
||||
|
||||
// Fetch apontamentos com join de peca e processo
|
||||
// Fetch apontamentos com join de peca e processo (apenas peças mestres)
|
||||
const { data: apontamentosData, error: errorApontamentos } = await supabase
|
||||
.from('apontamentos_producao' as any)
|
||||
.select(`
|
||||
id, of_number, peca_id, quantidade_produzida,
|
||||
id, of_number, peca_id, quantidade_produzida, tipo_apontamento, componente_id,
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
|
||||
`)
|
||||
@@ -188,6 +188,10 @@ export default function Visualizador3D() {
|
||||
apontamentosData.sort((a: any, b: any) => (a.processo?.ordem || 0) - (b.processo?.ordem || 0));
|
||||
|
||||
apontamentosData.forEach((ap: any) => {
|
||||
// Ignora apontamentos de componentes para não inflar as peças mestres
|
||||
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
|
||||
if (ap.componente_id) return;
|
||||
|
||||
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
|
||||
const fase = ap.peca?.etapa_fase ? String(ap.peca.etapa_fase).trim() : '';
|
||||
if (marca) {
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Scissors,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Zap,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Layers,
|
||||
Wrench,
|
||||
Flame
|
||||
} from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { smartAudio } from '@/utils/smartAudio';
|
||||
import { toast } from 'sonner';
|
||||
import { PecaKitInfo, ComponenteKitInfo } from '@/hooks/useKitsProducao';
|
||||
import { sincronizarPecaKitNoCorte } from '@/utils/sincronizacaoKitsCorte';
|
||||
|
||||
interface SmartComponentesCorteModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pecaKit: PecaKitInfo | null;
|
||||
ofNumber: string;
|
||||
processoCorteId: string;
|
||||
onApontamentoSalvo: () => void;
|
||||
}
|
||||
|
||||
export const SmartComponentesCorteModal: React.FC<SmartComponentesCorteModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
pecaKit,
|
||||
ofNumber,
|
||||
processoCorteId,
|
||||
onApontamentoSalvo,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [salvando, setSalvando] = useState<boolean>(false);
|
||||
const [quantidadesManual, setQuantidadesManual] = useState<Record<string, number>>({});
|
||||
|
||||
if (!pecaKit) return null;
|
||||
|
||||
const hoje = new Date().toISOString().split('T')[0];
|
||||
const nomeUsuario = user?.user_metadata?.nome || user?.email?.split('@')[0] || 'Operador Corte';
|
||||
|
||||
// Atualizar valor digitado para um componente específico
|
||||
const setQtdComp = (compId: string, valor: number) => {
|
||||
setQuantidadesManual((prev) => ({
|
||||
...prev,
|
||||
[compId]: Math.max(1, valor),
|
||||
}));
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// AÇÃO 1: CORTAR 1 KIT COMPLETO (ATALHO RÁPIDO)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const handleCortarKitCompleto = async (multiplicadorKits: number = 1) => {
|
||||
try {
|
||||
setSalvando(true);
|
||||
smartAudio.playClick();
|
||||
|
||||
if (!pecaKit.componentes || pecaKit.componentes.length === 0) {
|
||||
toast.error('Esta peça não possui subcomponentes cadastrados.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Preparar lote de inserções para cada componente da peça
|
||||
const batchPayloads: Array<{
|
||||
of_number: string;
|
||||
peca_id: string;
|
||||
componente_id: string;
|
||||
tipo_apontamento: 'componente';
|
||||
processo_id: string;
|
||||
quantidade_produzida: number;
|
||||
data_apontamento: string;
|
||||
created_by?: string;
|
||||
usuario_nome: string;
|
||||
observacoes: string;
|
||||
}> = [];
|
||||
|
||||
let pesoTotalKg = 0;
|
||||
|
||||
for (const comp of pecaKit.componentes) {
|
||||
const porKit = comp.quantidade_por_peca || 1;
|
||||
const qtdParaApontar = porKit * multiplicadorKits;
|
||||
const restante = Math.max(0, comp.quantidade_total_necessaria - comp.quantidade_cortada);
|
||||
|
||||
// Não apontar além do total se já estiver concluído, exceto se solicitado
|
||||
const qtdFinal = Math.min(qtdParaApontar, Math.max(qtdParaApontar, restante));
|
||||
|
||||
if (qtdFinal > 0) {
|
||||
batchPayloads.push({
|
||||
of_number: ofNumber,
|
||||
peca_id: pecaKit.peca_id,
|
||||
componente_id: comp.id,
|
||||
tipo_apontamento: 'componente',
|
||||
processo_id: processoCorteId,
|
||||
quantidade_produzida: qtdFinal,
|
||||
data_apontamento: hoje,
|
||||
created_by: user?.id,
|
||||
usuario_nome: nomeUsuario,
|
||||
observacoes: `Corte de Kit Completo (${multiplicadorKits}x ${pecaKit.marca}) via Modo Smart`,
|
||||
});
|
||||
|
||||
pesoTotalKg += qtdFinal * (comp.peso_unitario || 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (batchPayloads.length === 0) {
|
||||
toast.info('Todos os componentes desta peça já estão 100% cortados!');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('apontamentos_producao').insert(batchPayloads);
|
||||
if (error) throw error;
|
||||
|
||||
// Sincronizar liberação de kits completos da peça mestre no Corte para a Solda
|
||||
const resSync = await sincronizarPecaKitNoCorte({
|
||||
ofNumber,
|
||||
pecaId: pecaKit.peca_id,
|
||||
processoCorteId,
|
||||
userId: user?.id,
|
||||
usuarioNome: nomeUsuario,
|
||||
dataApontamento: hoje,
|
||||
});
|
||||
|
||||
smartAudio.playSuccess();
|
||||
const infoLiberado =
|
||||
resSync && resSync.delta_liberado > 0
|
||||
? ` (+${resSync.delta_liberado} peça(s) liberada(s) para Solda)`
|
||||
: '';
|
||||
toast.success(
|
||||
`✅ ${multiplicadorKits} Kit Completo da peça ${pecaKit.marca} cortado com sucesso! (+${pesoTotalKg.toFixed(1)} kg)${infoLiberado}`
|
||||
);
|
||||
|
||||
onApontamentoSalvo();
|
||||
onOpenChange(false);
|
||||
} catch (err: any) {
|
||||
smartAudio.playAlert();
|
||||
console.error('Erro ao cortar kit completo:', err);
|
||||
toast.error('Erro ao registrar kit completo: ' + (err?.message || 'Falha de rede'));
|
||||
} finally {
|
||||
setSalvando(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// AÇÃO 2: APONTAR COMPONENTE INDIVIDUAL (DESBALANCEADO / NESTING)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const handleApontarComponenteIndividual = async (comp: ComponenteKitInfo) => {
|
||||
try {
|
||||
const qtd = quantidadesManual[comp.id] || 1;
|
||||
if (qtd <= 0) return;
|
||||
|
||||
setSalvando(true);
|
||||
smartAudio.playClick();
|
||||
|
||||
const payload = {
|
||||
of_number: ofNumber,
|
||||
peca_id: pecaKit.peca_id,
|
||||
componente_id: comp.id,
|
||||
tipo_apontamento: 'componente' as const,
|
||||
processo_id: processoCorteId,
|
||||
quantidade_produzida: qtd,
|
||||
data_apontamento: hoje,
|
||||
created_by: user?.id,
|
||||
usuario_nome: nomeUsuario,
|
||||
observacoes: `Corte de subcomponente ${comp.marca_componente} (${qtd} un) via Modo Smart`,
|
||||
};
|
||||
|
||||
const { error } = await supabase.from('apontamentos_producao').insert(payload);
|
||||
if (error) throw error;
|
||||
|
||||
// Sincronizar se este corte completou novos kits completos (respeitando estritamente o gargalo de todos os componentes)
|
||||
const resSync = await sincronizarPecaKitNoCorte({
|
||||
ofNumber,
|
||||
pecaId: pecaKit.peca_id,
|
||||
processoCorteId,
|
||||
userId: user?.id,
|
||||
usuarioNome: nomeUsuario,
|
||||
dataApontamento: hoje,
|
||||
});
|
||||
|
||||
const pesoKg = qtd * (comp.peso_unitario || 0);
|
||||
smartAudio.playSuccess();
|
||||
if (resSync && resSync.delta_liberado > 0) {
|
||||
toast.success(
|
||||
`✂️ Apontado no Corte: ${qtd}x ${comp.marca_componente} (+${pesoKg.toFixed(1)} kg) • ⚡ Kit completo formado: +${resSync.delta_liberado} un de ${pecaKit.marca} liberada(s) para Solda!`
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`✂️ Apontado no Corte: ${qtd}x ${comp.marca_componente} (+${pesoKg.toFixed(1)} kg)`
|
||||
);
|
||||
}
|
||||
|
||||
// Limpar valor digitado
|
||||
setQuantidadesManual((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[comp.id];
|
||||
return next;
|
||||
});
|
||||
|
||||
onApontamentoSalvo();
|
||||
} catch (err: any) {
|
||||
smartAudio.playAlert();
|
||||
console.error('Erro ao apontar componente:', err);
|
||||
toast.error('Erro ao apontar componente: ' + (err?.message || 'Falha de rede'));
|
||||
} finally {
|
||||
setSalvando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const kitsProntos = pecaKit.kits_completos_cortados;
|
||||
const totalKits = pecaKit.quantidade_total;
|
||||
const pct = pecaKit.percentual_termico;
|
||||
const gargalo = pecaKit.componente_gargalo;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader className="pb-2 border-b border-slate-200 dark:border-slate-800">
|
||||
<DialogTitle className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 rounded-xl bg-orange-100 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400">
|
||||
<Scissors className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-black text-amber-700 dark:text-amber-400 tracking-wider">
|
||||
{pecaKit.marca}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs font-bold">
|
||||
{pecaKit.etapa_fase.toLowerCase().startsWith('fase')
|
||||
? pecaKit.etapa_fase
|
||||
: `Fase ${pecaKit.etapa_fase}`}
|
||||
</Badge>
|
||||
{pecaKit.perfil_principal && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 font-mono font-bold">
|
||||
{pecaKit.perfil_principal}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{pecaKit.descricao || 'Peça Estrutural'} • Total: <strong>{totalKits} un</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-xs font-black ${
|
||||
kitsProntos >= totalKits
|
||||
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: kitsProntos > 0
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
|
||||
}`}
|
||||
>
|
||||
<Flame className="w-3.5 h-3.5 mr-1 animate-pulse" />
|
||||
{kitsProntos}/{totalKits} Kits Montáveis ({pct}%)
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* ──────────────── Barra de Prontidão e Gargalo ──────────────── */}
|
||||
<div className="p-3 rounded-2xl bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300">
|
||||
<span>Prontidão Térmica do Kit de Montagem</span>
|
||||
<span>{pct}% dos componentes cortados</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-slate-200 dark:bg-slate-800 h-3 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
kitsProntos >= totalKits
|
||||
? 'bg-emerald-500'
|
||||
: kitsProntos > 0
|
||||
? 'bg-blue-500'
|
||||
: 'bg-amber-500'
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{gargalo && kitsProntos < totalKits && (
|
||||
<div className="p-2 rounded-xl bg-amber-100/70 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 flex items-center gap-2 text-xs text-amber-900 dark:text-amber-300 font-semibold">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0 animate-bounce" />
|
||||
<span>
|
||||
<strong>Gargalo do Próximo Kit:</strong> Falta cortar{' '}
|
||||
<span className="underline decoration-amber-500 font-black">
|
||||
{gargalo.falta_para_prox_kit} un do componente {gargalo.marca_componente}
|
||||
</span>{' '}
|
||||
para liberar mais 1 kit completo na Solda!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ──────────────── ATALHO MESTRE: CORTAR 1 KIT COMPLETO ──────────────── */}
|
||||
<div className="p-3.5 rounded-2xl bg-gradient-to-r from-amber-500/10 via-amber-500/20 to-orange-500/10 border-2 border-amber-500/40 dark:border-amber-500/30 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-black uppercase text-amber-800 dark:text-amber-400">
|
||||
<Zap className="w-4 h-4 text-amber-600 dark:text-amber-400 fill-current" />
|
||||
<span>Atalho Rápido de Corte por Conjunto</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300 font-medium">
|
||||
Corta todos os componentes necessários para fechar mais 1 kit montável de uma só vez.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={salvando || kitsProntos >= totalKits}
|
||||
onClick={() => handleCortarKitCompleto(1)}
|
||||
className="w-full sm:w-auto h-11 px-4 bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 font-black text-xs uppercase tracking-wider rounded-xl shadow-md active:scale-95 transition-all flex items-center justify-center gap-1.5 shrink-0"
|
||||
>
|
||||
<Zap className="w-4 h-4 fill-current" />
|
||||
<span>CORTAR 1 KIT COMPLETO (+1 UN)</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ──────────────── LISTA DE SUBCOMPONENTES PARA CORTE AVULSO ──────────────── */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300 mb-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Wrench className="w-3.5 h-3.5 text-blue-500" />
|
||||
Corte Individual de Subcomponentes (Nesting / Chapas avulsas):
|
||||
</span>
|
||||
<span className="text-slate-400">{pecaKit.componentes.length} subcomponentes</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{pecaKit.componentes.map((comp) => {
|
||||
const isGargalo = gargalo?.id === comp.id && kitsProntos < totalKits;
|
||||
const comp100 = comp.concluido;
|
||||
const valorAtual = quantidadesManual[comp.id] || comp.quantidade_por_peca || 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className={`p-3 rounded-2xl border transition-all ${
|
||||
isGargalo
|
||||
? 'bg-amber-50/70 dark:bg-amber-950/30 border-amber-400 dark:border-amber-700 shadow-sm'
|
||||
: comp100
|
||||
? 'bg-emerald-50/40 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-800'
|
||||
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5">
|
||||
{/* Info do componente */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono font-black text-base text-slate-900 dark:text-slate-100">
|
||||
{comp.marca_componente}
|
||||
</span>
|
||||
{comp.perfil && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 font-mono text-slate-700 dark:text-slate-300 font-bold">
|
||||
{comp.perfil}
|
||||
</span>
|
||||
)}
|
||||
{isGargalo && (
|
||||
<span className="text-[10px] font-black px-2 py-0.5 rounded bg-amber-500 text-slate-950 uppercase tracking-tight">
|
||||
Gargalo do Kit
|
||||
</span>
|
||||
)}
|
||||
{comp100 && (
|
||||
<span className="text-[10px] font-black px-2 py-0.5 rounded bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
✅ 100% Cortado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
{comp.quantidade_por_peca} un/peça • Total da OF:{' '}
|
||||
<strong>{comp.quantidade_total_necessaria} un</strong> • Já cortado:{' '}
|
||||
<strong className="text-slate-900 dark:text-slate-100 font-mono">
|
||||
{comp.quantidade_cortada} un
|
||||
</strong>{' '}
|
||||
({comp.percentual_cortado}%) • {comp.peso_unitario > 0 ? `${comp.peso_unitario.toFixed(2)} kg/un` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teclado de corte rápido deste componente */}
|
||||
<div className="flex items-center gap-1.5 shrink-0 self-end sm:self-center">
|
||||
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQtdComp(comp.id, comp.quantidade_por_peca)}
|
||||
className="px-2 py-1 rounded-lg text-xs font-black bg-white dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 shadow-xs"
|
||||
title="1 kit da peça"
|
||||
>
|
||||
+{comp.quantidade_por_peca}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQtdComp(comp.id, comp.quantidade_por_peca * 2)}
|
||||
className="px-2 py-1 rounded-lg text-xs font-black bg-white dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 shadow-xs"
|
||||
title="2 kits da peça"
|
||||
>
|
||||
+{comp.quantidade_por_peca * 2}
|
||||
</button>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={valorAtual}
|
||||
onChange={(e) => setQtdComp(comp.id, parseInt(e.target.value) || 1)}
|
||||
className="w-14 h-7 text-xs text-center font-bold px-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={salvando}
|
||||
onClick={() => handleApontarComponenteIndividual(comp)}
|
||||
className="h-8 px-3 bg-slate-900 hover:bg-slate-800 text-white dark:bg-amber-500 dark:hover:bg-amber-400 dark:text-slate-950 font-black text-xs uppercase rounded-xl active:scale-95"
|
||||
>
|
||||
Apontar ({valorAtual})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -90,10 +90,10 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
|
||||
.from('processos_fabricacao')
|
||||
.select('id, nome, ordem'),
|
||||
|
||||
// 3. Apontamentos da OF
|
||||
// 3. Apontamentos da OF (apenas peças mestres)
|
||||
supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('peca_id, processo_id, quantidade_produzida')
|
||||
.select('peca_id, processo_id, quantidade_produzida, tipo_apontamento, componente_id')
|
||||
.eq('of_number', obra.of_number),
|
||||
|
||||
// 4. Expedição em Romaneios (APENAS ROMANEIOS ENTREGUES E MAPEANDO POR PECA_ID PARA EVITAR CONFLITO DE FASE)
|
||||
@@ -132,6 +132,10 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
|
||||
>();
|
||||
|
||||
(resApontamentos.data || []).forEach((ap: any) => {
|
||||
// Ignora apontamentos de componentes para não inflar as quantidades da peça mestre
|
||||
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
|
||||
if (ap.componente_id) return;
|
||||
|
||||
const pecaId = ap.peca_id;
|
||||
const nomeProc = mapaNomeProcesso.get(ap.processo_id) || '';
|
||||
const qtd = Number(ap.quantidade_produzida || 0);
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
Filter,
|
||||
Delete,
|
||||
Pencil,
|
||||
CheckCheck
|
||||
CheckCheck,
|
||||
Flame
|
||||
} from 'lucide-react';
|
||||
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||
import { useProcessosFabricacao, ProcessoFabricacao } from '@/hooks/useProcessosFabricacao';
|
||||
@@ -25,6 +26,10 @@ import { smartAudio } from '@/utils/smartAudio';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { SmartForcarApontamentoModal } from './SmartForcarApontamentoModal';
|
||||
import { calcularKitsOF, PecaKitInfo } from '@/hooks/useKitsProducao';
|
||||
import { MapaTermicoKitsModal } from '@/components/apontamento/kits/MapaTermicoKitsModal';
|
||||
import { SmartComponentesCorteModal } from './SmartComponentesCorteModal';
|
||||
import { sincronizarTodosKitsOFCorte } from '@/utils/sincronizacaoKitsCorte';
|
||||
|
||||
interface SmartProducaoFlowProps {
|
||||
obra: OFAtiva;
|
||||
@@ -58,7 +63,8 @@ export const calcularSaldoProcessoPeca = (
|
||||
peca: PecaData,
|
||||
processoAtual: ProcessoFabricacao,
|
||||
todosProcessos: ProcessoFabricacao[],
|
||||
mapaProd: Map<string, number>
|
||||
mapaProd: Map<string, number>,
|
||||
mapaKits?: Map<string, PecaKitInfo>
|
||||
): CalculoSaldoPeca => {
|
||||
const ehSemMontagem = peca.tem_componentes === false;
|
||||
const keyAtual = `${peca.id}_${processoAtual.id}`;
|
||||
@@ -68,15 +74,27 @@ export const calcularSaldoProcessoPeca = (
|
||||
const indexAtual = procsOrdenados.findIndex((p) => p.id === processoAtual.id);
|
||||
const nomeProcAtual = (processoAtual.nome || '').toLowerCase();
|
||||
|
||||
const ehSolda = nomeProcAtual.includes('solda');
|
||||
const ehSolda = nomeProcAtual.includes('solda') || nomeProcAtual.includes('montag');
|
||||
|
||||
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
|
||||
let motivoBloqueio: string | null = null;
|
||||
|
||||
const kitInfo = mapaKits?.get(peca.id);
|
||||
const temControleComponentes = Boolean(kitInfo && kitInfo.tem_componentes && (kitInfo.componentes?.length || 0) > 0);
|
||||
|
||||
// REGRA 1: Peças sem montagem (S/M) não passam por Solda
|
||||
if (ehSemMontagem && ehSolda) {
|
||||
qtdDisponivelParaEntrar = 0;
|
||||
motivoBloqueio = 'Peça S/M (Pula Solda - vai direto para Pintura)';
|
||||
} else if (ehSolda && !ehSemMontagem && temControleComponentes && kitInfo) {
|
||||
// REGRA 1.1: REGRA DO KIT COMPLETO DE CORTE PARA A SOLDA!
|
||||
// Apenas peças com componentes 100% cortados (em lotes de kits inteiros) são liberadas para Solda!
|
||||
qtdDisponivelParaEntrar = kitInfo.kits_completos_cortados;
|
||||
if (kitInfo.kits_completos_cortados === 0) {
|
||||
motivoBloqueio = kitInfo.motivo_bloqueio_solda || 'Aguardando corte completo dos kits';
|
||||
} else if (kitInfo.kits_completos_cortados <= jaProduzidoAtual) {
|
||||
motivoBloqueio = `Todos os ${kitInfo.kits_completos_cortados} kits cortados já foram soldados (${jaProduzidoAtual}/${peca.quantidade})`;
|
||||
}
|
||||
} else if (indexAtual > 0) {
|
||||
// REGRA 2: Encontrar processo anterior válido
|
||||
let procAnteriorValido: ProcessoFabricacao | null = null;
|
||||
@@ -94,7 +112,8 @@ export const calcularSaldoProcessoPeca = (
|
||||
if (procAnteriorValido) {
|
||||
const keyAnt = `${peca.id}_${procAnteriorValido.id}`;
|
||||
const qtdApontadaAnterior = mapaProd.get(keyAnt) || 0;
|
||||
qtdDisponivelParaEntrar = qtdApontadaAnterior;
|
||||
// Trava de teto: o saldo para entrar jamais pode exceder o total da peça
|
||||
qtdDisponivelParaEntrar = Math.min(Number(peca.quantidade) || 0, qtdApontadaAnterior);
|
||||
|
||||
if (qtdApontadaAnterior === 0) {
|
||||
motivoBloqueio = `Aguardando ${procAnteriorValido.nome}`;
|
||||
@@ -127,7 +146,7 @@ export const calcularSaldoProcessoPeca = (
|
||||
}
|
||||
}
|
||||
|
||||
const saldo = Math.max(0, qtdDisponivelParaEntrar - jaProduzidoAtual);
|
||||
const saldo = Math.max(0, Math.min(Number(peca.quantidade) || 0, qtdDisponivelParaEntrar) - jaProduzidoAtual);
|
||||
|
||||
return {
|
||||
saldo,
|
||||
@@ -149,6 +168,8 @@ interface ApontamentoItem {
|
||||
status_confirmacao?: string;
|
||||
forcado_por_user_nome?: string;
|
||||
usuario_nome?: string;
|
||||
tipo_apontamento?: string;
|
||||
componente_id?: string | null;
|
||||
}
|
||||
|
||||
export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
@@ -173,12 +194,17 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
// Modal de Forçar Apontamento
|
||||
const [isForcarModalOpen, setIsForcarModalOpen] = useState<boolean>(false);
|
||||
|
||||
// Estados do Mapa Térmico de Kits e Corte de Componentes
|
||||
const [isMapaKitsOpen, setIsMapaKitsOpen] = useState<boolean>(false);
|
||||
const [pecaParaCorteModal, setPecaParaCorteModal] = useState<PecaKitInfo | null>(null);
|
||||
const [componentesOF, setComponentesOF] = useState<any[]>([]);
|
||||
|
||||
// Dados carregados da OF
|
||||
const [pecas, setPecas] = useState<PecaData[]>([]);
|
||||
const [apontamentosExistentes, setApontamentosExistentes] = useState<ApontamentoItem[]>([]);
|
||||
const [carregandoDados, setCarregandoDados] = useState<boolean>(true);
|
||||
|
||||
// Carregar peças e apontamentos da OF
|
||||
// Carregar peças, componentes e apontamentos da OF
|
||||
const carregarDadosOF = React.useCallback(async () => {
|
||||
try {
|
||||
setCarregandoDados(true);
|
||||
@@ -190,12 +216,28 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
.eq('of_number', obra.of_number),
|
||||
supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('id, peca_id, processo_id, quantidade_produzida, is_forcado, status_confirmacao, forcado_por_user_nome, usuario_nome')
|
||||
.select('id, peca_id, processo_id, quantidade_produzida, is_forcado, status_confirmacao, forcado_por_user_nome, usuario_nome, tipo_apontamento, componente_id')
|
||||
.eq('of_number', obra.of_number),
|
||||
]);
|
||||
|
||||
if (resPecas.data) {
|
||||
setPecas(resPecas.data);
|
||||
|
||||
// Buscar componentes das peças da OF em lotes
|
||||
const pecaIds = resPecas.data.map((p) => p.id);
|
||||
if (pecaIds.length > 0) {
|
||||
let todosComps: any[] = [];
|
||||
for (let i = 0; i < pecaIds.length; i += 300) {
|
||||
const chunk = pecaIds.slice(i, i + 300);
|
||||
const { data: compsData } = await supabase
|
||||
.from('componentes_peca')
|
||||
.select('id, peca_id, marca_componente, descricao, perfil, peso_unitario, quantidade_por_peca')
|
||||
.in('peca_id', chunk);
|
||||
|
||||
if (compsData) todosComps = [...todosComps, ...compsData];
|
||||
}
|
||||
setComponentesOF(todosComps);
|
||||
}
|
||||
}
|
||||
if (resApontamentos.data) {
|
||||
setApontamentosExistentes(resApontamentos.data as any);
|
||||
@@ -224,10 +266,14 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
return lista.length > 0 ? lista : ['Geral'];
|
||||
}, [pecas]);
|
||||
|
||||
// Mapa de produção acumulada por peca_id e processo_id
|
||||
// Mapa de produção acumulada por peca_id e processo_id (apenas apontamentos de peças mestres)
|
||||
const mapaProducao = useMemo(() => {
|
||||
const mapa = new Map<string, number>();
|
||||
apontamentosExistentes.forEach((ap) => {
|
||||
// Ignora apontamentos de componentes para não inflar a contagem da peça pai
|
||||
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
|
||||
if (ap.componente_id) return;
|
||||
|
||||
const key = `${ap.peca_id}_${ap.processo_id}`;
|
||||
const atual = mapa.get(key) || 0;
|
||||
mapa.set(key, atual + Number(ap.quantidade_produzida || 0));
|
||||
@@ -238,7 +284,11 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
// Apontamentos forçados que ainda estão pendentes de confirmação de baixa
|
||||
const apontamentosPendentes = useMemo(() => {
|
||||
return apontamentosExistentes.filter(
|
||||
(ap) => ap.is_forcado && ap.status_confirmacao === 'pendente_confirmacao'
|
||||
(ap) =>
|
||||
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
|
||||
!ap.componente_id &&
|
||||
ap.is_forcado &&
|
||||
ap.status_confirmacao === 'pendente_confirmacao'
|
||||
);
|
||||
}, [apontamentosExistentes]);
|
||||
|
||||
@@ -281,6 +331,32 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
return map;
|
||||
}, [apontamentosPendentes, processoSelecionado]);
|
||||
|
||||
// Identificar processos de Corte e Solda
|
||||
const procCorteId = useMemo(() => {
|
||||
return processos.find((p) => {
|
||||
const n = (p.nome || '').toLowerCase();
|
||||
return n.includes('corte') || n.includes('prepar') || p.ordem === 1;
|
||||
})?.id;
|
||||
}, [processos]);
|
||||
|
||||
const procSoldaId = useMemo(() => {
|
||||
return processos.find((p) => {
|
||||
const n = (p.nome || '').toLowerCase();
|
||||
return n.includes('solda') || n.includes('montag') || p.ordem === 3;
|
||||
})?.id;
|
||||
}, [processos]);
|
||||
|
||||
// Mapa Térmico e de Kits completo para toda a OF
|
||||
const mapaKits = useMemo(() => {
|
||||
return calcularKitsOF(
|
||||
pecas,
|
||||
componentesOF,
|
||||
apontamentosExistentes,
|
||||
procCorteId,
|
||||
procSoldaId
|
||||
);
|
||||
}, [pecas, componentesOF, apontamentosExistentes, procCorteId, procSoldaId]);
|
||||
|
||||
// Peças filtradas pela fase selecionada com cálculo estrito de precedência de processos e regra S/M
|
||||
const pecasComSaldo = useMemo(() => {
|
||||
if (!processoSelecionado) return [];
|
||||
@@ -291,7 +367,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
return (p.etapa_fase || 'Geral').trim() === faseSelecionada;
|
||||
})
|
||||
.map((p) => {
|
||||
const info = calcularSaldoProcessoPeca(p, processoSelecionado, processos, mapaProducao);
|
||||
const info = calcularSaldoProcessoPeca(p, processoSelecionado, processos, mapaProducao, mapaKits);
|
||||
return {
|
||||
...p,
|
||||
...info,
|
||||
@@ -309,7 +385,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
if (a.saldo === 0 && b.saldo > 0) return 1;
|
||||
return a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
}, [pecas, faseSelecionada, processoSelecionado, mapaProducao, processos, pecasPendentesMap]);
|
||||
}, [pecas, faseSelecionada, processoSelecionado, mapaProducao, processos, pecasPendentesMap, mapaKits]);
|
||||
|
||||
// Helper de ícone por processo
|
||||
const getIconeProcesso = (nome: string) => {
|
||||
@@ -328,6 +404,23 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
smartAudio.playClick();
|
||||
setProcessoSelecionado(proc);
|
||||
setEtapaAtual('fase');
|
||||
|
||||
// Ao selecionar Solda ou Montagem, rodar sincronização preventiva de kits da OF
|
||||
const nomeP = (proc.nome || '').toLowerCase();
|
||||
if ((nomeP.includes('solda') || nomeP.includes('montag')) && procCorteId && obra.of_number) {
|
||||
sincronizarTodosKitsOFCorte({
|
||||
ofNumber: obra.of_number,
|
||||
processoCorteId: procCorteId,
|
||||
userId: user?.id,
|
||||
usuarioNome: user?.name || user?.username || 'Operador',
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.some((r) => r.delta_liberado > 0)) {
|
||||
carregarDadosOF();
|
||||
}
|
||||
})
|
||||
.catch((e) => console.warn('Erro na sincronização preventiva de kits:', e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscolherFase = (fase: string) => {
|
||||
@@ -388,7 +481,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const info = calcularSaldoProcessoPeca(pecaSelecionada, processoSelecionado, processos, mapaProducao);
|
||||
const info = calcularSaldoProcessoPeca(pecaSelecionada, processoSelecionado, processos, mapaProducao, mapaKits);
|
||||
if (modoEdicao && qtdApontar < info.minPermitido) {
|
||||
smartAudio.playAlert();
|
||||
toast.error(`Não é possível reduzir para ${qtdApontar}. O processo seguinte (${info.procSeguinteNome}) já consumiu ${info.minPermitido} peças. Ajuste o processo seguinte primeiro.`);
|
||||
@@ -749,6 +842,19 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
>
|
||||
<span>🏢 {faseSelecionada}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
smartAudio.playClick();
|
||||
setIsMapaKitsOpen(true);
|
||||
}}
|
||||
className="px-2.5 py-1 rounded-lg bg-orange-100 text-orange-800 dark:bg-orange-500/20 dark:text-orange-300 font-bold text-xs flex items-center gap-1 border border-orange-200 dark:border-orange-500/30 hover:bg-orange-200 dark:hover:bg-orange-500/30 shrink-0 transition-colors"
|
||||
title="Abrir Mapa Térmico de Kits de Montagem"
|
||||
>
|
||||
<Flame className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400 fill-current animate-pulse" />
|
||||
<span>Mapa de Kits</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -794,6 +900,10 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
const temPendencia = pendenciasItem.length > 0;
|
||||
const concluida = item.jaProduzido >= item.quantidade;
|
||||
const aguardandoAnterior = !concluida && item.saldo <= 0;
|
||||
const kitInfo = mapaKits.get(item.id);
|
||||
const ehCorte = processoSelecionado?.id === procCorteId;
|
||||
const ehSolda = processoSelecionado?.id === procSoldaId;
|
||||
const temComponentes = item.tem_componentes !== false && (kitInfo?.componentes.length || 0) > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -875,7 +985,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-slate-600 dark:text-slate-400 px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 mt-1" title={item.motivoBloqueio || 'Aguardando processo anterior'}>
|
||||
{item.motivoBloqueio ? item.motivoBloqueio.slice(0, 22) : 'Aguardando anterior'}
|
||||
{item.motivoBloqueio ? item.motivoBloqueio.slice(0, 26) : 'Aguardando anterior'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -893,6 +1003,68 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MAPA TÉRMICO DE KITS NO CARD (QUANDO PEÇA POSSUI COMPONENTES) */}
|
||||
{kitInfo && temComponentes && (
|
||||
<div className="mt-2.5 p-2.5 rounded-xl bg-orange-50/70 dark:bg-orange-950/20 border border-orange-200/80 dark:border-orange-800/40 flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-1.5 font-bold">
|
||||
<Flame
|
||||
className={`w-3.5 h-3.5 shrink-0 ${
|
||||
kitInfo.kits_completos_cortados > 0
|
||||
? 'text-orange-500 fill-orange-500 animate-pulse'
|
||||
: 'text-slate-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-slate-800 dark:text-slate-200 font-extrabold text-[11px] uppercase tracking-wide">
|
||||
Kits Montáveis no Corte:
|
||||
</span>
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-[11px] font-black ${
|
||||
kitInfo.kits_completos_cortados >= item.quantidade
|
||||
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: kitInfo.kits_completos_cortados > 0
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
|
||||
: 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{kitInfo.kits_completos_cortados} de {item.quantidade} un
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-black text-orange-700 dark:text-orange-400">
|
||||
{kitInfo.percentual_termico}% peso
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mini barra térmica do kit com gradiente */}
|
||||
<div className="w-full bg-slate-200 dark:bg-slate-800 h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
kitInfo.percentual_termico >= 100
|
||||
? 'bg-emerald-500'
|
||||
: kitInfo.percentual_termico >= 50
|
||||
? 'bg-amber-500'
|
||||
: kitInfo.percentual_termico > 0
|
||||
? 'bg-orange-500'
|
||||
: 'bg-slate-300 dark:bg-slate-700'
|
||||
}`}
|
||||
style={{ width: `${kitInfo.percentual_termico}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gargalo se houver */}
|
||||
{kitInfo.componente_gargalo && kitInfo.kits_completos_cortados < item.quantidade && (
|
||||
<div className="text-[10px] text-amber-800 dark:text-amber-300 font-medium flex items-center justify-between">
|
||||
<span className="truncate">
|
||||
⚠️ Gargalo: <strong>{kitInfo.componente_gargalo.marca_componente || kitInfo.componente_gargalo.descricao}</strong> (falta {kitInfo.componente_gargalo.falta_para_prox_kit} un p/ próx kit)
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 shrink-0 ml-1">
|
||||
{kitInfo.componentes.length} itens
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ALERTA VISUAL: BAIXA FORÇADA PENDENTE DE CONFIRMAÇÃO */}
|
||||
{temPendencia && (
|
||||
<div className="mt-3 p-2.5 rounded-xl bg-red-100 dark:bg-red-950/60 border border-red-300 dark:border-red-800 flex items-center justify-between gap-2 flex-wrap">
|
||||
@@ -916,40 +1088,107 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
|
||||
{/* Botões de Ação no Card */}
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={concluida || item.saldo <= 0}
|
||||
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
|
||||
className={`sm:col-span-2 h-13 text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
|
||||
concluida
|
||||
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
|
||||
: item.saldo <= 0
|
||||
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
|
||||
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
|
||||
}`}
|
||||
>
|
||||
{concluida
|
||||
? 'Processo Concluído'
|
||||
: item.saldo <= 0
|
||||
? item.motivoBloqueio || 'Aguardando processo anterior'
|
||||
: `DIGITAR / ESCOLHER QUANTIDADE`}
|
||||
</Button>
|
||||
{ehCorte && temComponentes && kitInfo ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
smartAudio.playClick();
|
||||
setPecaParaCorteModal(kitInfo);
|
||||
}}
|
||||
className="sm:col-span-2 h-13 text-xs sm:text-sm font-black rounded-xl uppercase tracking-wider bg-orange-600 hover:bg-orange-500 active:bg-orange-700 text-white shadow-md shadow-orange-950/20 active:scale-95 transition-all flex items-center justify-center gap-2"
|
||||
title="Apontar componentes individuais cortados ou cortar 1 kit completo"
|
||||
>
|
||||
<Scissors className="w-4 h-4 shrink-0" />
|
||||
<span>Cortar Componentes ({kitInfo.componentes.length} itens)</span>
|
||||
</Button>
|
||||
|
||||
{!concluida && item.saldo > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
smartAudio.playClick();
|
||||
setPecaSelecionada(item);
|
||||
setQtdApontar(item.saldo);
|
||||
setModoEdicao(false);
|
||||
setEtapaAtual('quantidade');
|
||||
}}
|
||||
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
|
||||
title="Apontar todo o saldo disponível de uma vez"
|
||||
>
|
||||
TODAS ({item.saldo})
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={concluida || item.saldo <= 0}
|
||||
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
|
||||
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors disabled:opacity-50"
|
||||
title="Apontar peça mestre inteira"
|
||||
>
|
||||
Peça Cheia ({item.saldo})
|
||||
</Button>
|
||||
</>
|
||||
) : ehSolda && temComponentes && kitInfo ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={concluida || item.saldo <= 0}
|
||||
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
|
||||
className={`sm:col-span-2 h-13 text-xs sm:text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
|
||||
concluida
|
||||
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
|
||||
: item.saldo <= 0
|
||||
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
|
||||
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
|
||||
}`}
|
||||
>
|
||||
{concluida
|
||||
? 'Processo Concluído'
|
||||
: item.saldo <= 0
|
||||
? (item.motivoBloqueio || 'Aguardando kits completos no Corte')
|
||||
: `⚡ SOLDAR (${item.saldo} KITS LIBERADOS)`}
|
||||
</Button>
|
||||
|
||||
{!concluida && item.saldo > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
smartAudio.playClick();
|
||||
setPecaSelecionada(item);
|
||||
setQtdApontar(item.saldo);
|
||||
setModoEdicao(false);
|
||||
setEtapaAtual('quantidade');
|
||||
}}
|
||||
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
|
||||
title="Apontar todos os kits disponíveis de uma vez"
|
||||
>
|
||||
TODAS ({item.saldo})
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={concluida || item.saldo <= 0}
|
||||
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
|
||||
className={`sm:col-span-2 h-13 text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
|
||||
concluida
|
||||
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
|
||||
: item.saldo <= 0
|
||||
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
|
||||
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
|
||||
}`}
|
||||
>
|
||||
{concluida
|
||||
? 'Processo Concluído'
|
||||
: item.saldo <= 0
|
||||
? item.motivoBloqueio || 'Aguardando processo anterior'
|
||||
: `DIGITAR / ESCOLHER QUANTIDADE`}
|
||||
</Button>
|
||||
|
||||
{!concluida && item.saldo > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
smartAudio.playClick();
|
||||
setPecaSelecionada(item);
|
||||
setQtdApontar(item.saldo);
|
||||
setModoEdicao(false);
|
||||
setEtapaAtual('quantidade');
|
||||
}}
|
||||
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
|
||||
title="Apontar todo o saldo disponível de uma vez"
|
||||
>
|
||||
TODAS ({item.saldo})
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -987,6 +1226,42 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal de Mapa Térmico de Kits */}
|
||||
<MapaTermicoKitsModal
|
||||
open={isMapaKitsOpen}
|
||||
onOpenChange={setIsMapaKitsOpen}
|
||||
ofNumber={obra.of_number}
|
||||
fase={faseSelecionada}
|
||||
onSelectPecaParaApontar={(pecaKit) => {
|
||||
const ehProcCorte = processoSelecionado?.id === procCorteId;
|
||||
if (ehProcCorte && pecaKit.tem_componentes && pecaKit.componentes.length > 0) {
|
||||
setPecaParaCorteModal(pecaKit);
|
||||
} else {
|
||||
const pItem = pecasComSaldo.find((p) => p.id === pecaKit.peca_id);
|
||||
if (pItem && pItem.saldo > 0) {
|
||||
handleIniciarApontamentoPeca(pItem, pItem.saldo);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modal de Apontamento de Componentes de Corte */}
|
||||
{pecaParaCorteModal && procCorteId && (
|
||||
<SmartComponentesCorteModal
|
||||
open={!!pecaParaCorteModal}
|
||||
onOpenChange={(aberto) => {
|
||||
if (!aberto) setPecaParaCorteModal(null);
|
||||
}}
|
||||
pecaKit={pecaParaCorteModal}
|
||||
ofNumber={obra.of_number}
|
||||
processoCorteId={procCorteId}
|
||||
onApontamentoSalvo={() => {
|
||||
carregarDadosOF();
|
||||
onApontamentoRealizado(1, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -999,7 +1274,8 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
pecaSelecionada,
|
||||
processoSelecionado,
|
||||
processos,
|
||||
mapaProducao
|
||||
mapaProducao,
|
||||
mapaKits
|
||||
);
|
||||
const saldoPendente = infoSaldoAtual.saldo;
|
||||
const maxPermitido = modoEdicao ? infoSaldoAtual.jaProduzido + infoSaldoAtual.saldo : infoSaldoAtual.saldo;
|
||||
|
||||
@@ -69,7 +69,7 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
|
||||
supabase
|
||||
.from('apontamentos_producao' as any)
|
||||
.select(`
|
||||
id, of_number, peca_id, quantidade_produzida,
|
||||
id, of_number, peca_id, quantidade_produzida, tipo_apontamento, componente_id,
|
||||
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
|
||||
`)
|
||||
@@ -139,6 +139,10 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
|
||||
);
|
||||
|
||||
sortedApontamentos.forEach((ap: any) => {
|
||||
// Ignora apontamentos de componentes para não inflar as peças mestres
|
||||
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
|
||||
if (ap.componente_id) return;
|
||||
|
||||
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
|
||||
const fase = ap.peca?.etapa_fase ? String(ap.peca.etapa_fase).trim() : '';
|
||||
if (marca) {
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
export interface ComponenteNecessario {
|
||||
id: string;
|
||||
quantidade_por_peca: number;
|
||||
marca_componente?: string;
|
||||
}
|
||||
|
||||
export interface ResultadoSincronizacaoPeca {
|
||||
peca_id: string;
|
||||
marca: string;
|
||||
quantidade_total: number;
|
||||
kits_completos: number;
|
||||
ja_apontado_anteriormente: number;
|
||||
delta_liberado: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula estritamente a quantidade de kits completos cortados para uma peça mestre.
|
||||
* Respeita a regra do GARGALO (bottleneck):
|
||||
* Se um ou mais componentes têm corte para N peças, mas qualquer outro componente
|
||||
* tem corte para apenas M peças (onde M < N), APENAS M peças são consideradas completas.
|
||||
* A sobra (N - M) NÃO é liberada para Solda!
|
||||
*/
|
||||
export function calcularKitsCompletosPeca(
|
||||
pecaQuantidade: number,
|
||||
componentes: ComponenteNecessario[],
|
||||
cortadoPorComponenteId: Map<string, number>
|
||||
): { kitsCompletos: number; menorComponenteId?: string; razaoPorComp: Map<string, number> } {
|
||||
const qtdTotal = Number(pecaQuantidade) || 0;
|
||||
if (qtdTotal <= 0 || !componentes || componentes.length === 0) {
|
||||
return { kitsCompletos: 0, razaoPorComp: new Map() };
|
||||
}
|
||||
|
||||
let menorKits = qtdTotal;
|
||||
let menorComponenteId: string | undefined = undefined;
|
||||
const razaoPorComp = new Map<string, number>();
|
||||
|
||||
for (const comp of componentes) {
|
||||
const cortado = cortadoPorComponenteId.get(comp.id) || 0;
|
||||
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
|
||||
const kitsDeste = Math.floor(cortado / porPeca);
|
||||
razaoPorComp.set(comp.id, kitsDeste);
|
||||
|
||||
if (kitsDeste < menorKits) {
|
||||
menorKits = kitsDeste;
|
||||
menorComponenteId = comp.id;
|
||||
}
|
||||
}
|
||||
|
||||
const kitsCompletos = Math.max(0, Math.min(qtdTotal, menorKits));
|
||||
return { kitsCompletos, menorComponenteId, razaoPorComp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza uma peça específica:
|
||||
* Verifica o corte de todos os seus componentes, calcula os kits 100% completos,
|
||||
* e gera o apontamento da peça mestre no processo de Corte para o delta liberado.
|
||||
*/
|
||||
export async function sincronizarPecaKitNoCorte(params: {
|
||||
ofNumber: string;
|
||||
pecaId: string;
|
||||
processoCorteId: string;
|
||||
userId?: string;
|
||||
usuarioNome?: string;
|
||||
dataApontamento?: string;
|
||||
}): Promise<ResultadoSincronizacaoPeca | null> {
|
||||
const { ofNumber, pecaId, processoCorteId, userId, usuarioNome, dataApontamento } = params;
|
||||
|
||||
try {
|
||||
// 1. Buscar a peça mestre
|
||||
const { data: peca, error: pecaError } = await supabase
|
||||
.from('pecas')
|
||||
.select('id, marca, quantidade, of_number, tem_componentes')
|
||||
.eq('id', pecaId)
|
||||
.single();
|
||||
|
||||
if (pecaError || !peca) {
|
||||
console.warn('Peça não encontrada para sincronização de kit:', pecaId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Se for peça S/M (sem componentes), ela não depende de kit
|
||||
if (peca.tem_componentes === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Buscar componentes cadastrados para esta peça
|
||||
const { data: comps, error: compsError } = await supabase
|
||||
.from('componentes_peca')
|
||||
.select('id, marca_componente, quantidade_por_peca')
|
||||
.eq('peca_id', pecaId);
|
||||
|
||||
if (compsError || !comps || comps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Buscar todos os apontamentos de componentes já feitos para essa peça no processo de Corte
|
||||
const { data: aptsComp, error: aptsCompError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('componente_id, quantidade_produzida')
|
||||
.eq('peca_id', pecaId)
|
||||
.eq('processo_id', processoCorteId)
|
||||
.eq('tipo_apontamento', 'componente');
|
||||
|
||||
if (aptsCompError) {
|
||||
console.error('Erro ao buscar apontamentos de componente:', aptsCompError);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cortadoPorComp = new Map<string, number>();
|
||||
(aptsComp || []).forEach((a) => {
|
||||
if (a.componente_id) {
|
||||
cortadoPorComp.set(
|
||||
a.componente_id,
|
||||
(cortadoPorComp.get(a.componente_id) || 0) + Number(a.quantidade_produzida || 0)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Calcular kits completos estritos
|
||||
const { kitsCompletos } = calcularKitsCompletosPeca(peca.quantidade, comps, cortadoPorComp);
|
||||
|
||||
// 5. Buscar quanto já foi apontado como peça mestre no corte
|
||||
const { data: aptsPeca, error: aptsPecaError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('quantidade_produzida')
|
||||
.eq('peca_id', pecaId)
|
||||
.eq('processo_id', processoCorteId)
|
||||
.eq('tipo_apontamento', 'peca');
|
||||
|
||||
if (aptsPecaError) {
|
||||
console.error('Erro ao buscar apontamentos de peça mestre:', aptsPecaError);
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalJaApontadoPeca = (aptsPeca || []).reduce(
|
||||
(sum, a) => sum + Number(a.quantidade_produzida || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const delta = kitsCompletos - totalJaApontadoPeca;
|
||||
|
||||
// 6. Se houver delta positivo de kits completos formados, registrar apontamento no Corte
|
||||
if (delta > 0) {
|
||||
const hoje = new Date().toISOString().split('T')[0];
|
||||
const payload = {
|
||||
of_number: peca.of_number,
|
||||
peca_id: peca.id,
|
||||
processo_id: processoCorteId,
|
||||
tipo_apontamento: 'peca' as const,
|
||||
quantidade_produzida: delta,
|
||||
data_apontamento: dataApontamento || hoje,
|
||||
created_by: userId || null,
|
||||
usuario_nome: usuarioNome || 'Sistema (Liberação de Kits)',
|
||||
observacoes: `Liberação automática de Kit 100% cortado (${kitsCompletos}/${peca.quantidade} un)`,
|
||||
status_confirmacao: 'confirmado',
|
||||
is_forcado: false,
|
||||
};
|
||||
|
||||
const { error: insertError } = await supabase.from('apontamentos_producao').insert(payload);
|
||||
if (insertError) {
|
||||
console.error('Erro ao inserir apontamento da peça mestre no corte:', insertError);
|
||||
throw insertError;
|
||||
}
|
||||
|
||||
console.log(`✅ Peça ${peca.marca}: +${delta} kit(s) completos liberados para Solda (${kitsCompletos}/${peca.quantidade})`);
|
||||
}
|
||||
|
||||
return {
|
||||
peca_id: peca.id,
|
||||
marca: peca.marca,
|
||||
quantidade_total: peca.quantidade,
|
||||
kits_completos: kitsCompletos,
|
||||
ja_apontado_anteriormente: totalJaApontadoPeca,
|
||||
delta_liberado: Math.max(0, delta),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao sincronizar kits da peça no corte:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza em lote todas as peças com componentes de uma OF.
|
||||
* Útil ao entrar no processo de Solda ou ao finalizar apontamentos em lote.
|
||||
*/
|
||||
export async function sincronizarTodosKitsOFCorte(params: {
|
||||
ofNumber: string;
|
||||
processoCorteId: string;
|
||||
userId?: string;
|
||||
usuarioNome?: string;
|
||||
}): Promise<ResultadoSincronizacaoPeca[]> {
|
||||
const { ofNumber, processoCorteId, userId, usuarioNome } = params;
|
||||
|
||||
try {
|
||||
// 1. Buscar peças com componentes da OF
|
||||
const { data: pecas, error: pecasError } = await supabase
|
||||
.from('pecas')
|
||||
.select('id, marca, quantidade, of_number, tem_componentes')
|
||||
.eq('of_number', ofNumber);
|
||||
|
||||
if (pecasError || !pecas || pecas.length === 0) return [];
|
||||
|
||||
const pecasComComponentes = pecas.filter((p) => p.tem_componentes !== false);
|
||||
if (pecasComComponentes.length === 0) return [];
|
||||
|
||||
const pecaIds = pecasComComponentes.map((p) => p.id);
|
||||
|
||||
// 2. Buscar componentes de todas as peças
|
||||
const { data: todosComponentes, error: compsError } = await supabase
|
||||
.from('componentes_peca')
|
||||
.select('id, peca_id, marca_componente, quantidade_por_peca')
|
||||
.in('peca_id', pecaIds);
|
||||
|
||||
if (compsError || !todosComponentes || todosComponentes.length === 0) return [];
|
||||
|
||||
const compsPorPeca = new Map<string, ComponenteNecessario[]>();
|
||||
todosComponentes.forEach((c) => {
|
||||
if (!compsPorPeca.has(c.peca_id)) {
|
||||
compsPorPeca.set(c.peca_id, []);
|
||||
}
|
||||
compsPorPeca.get(c.peca_id)!.push({
|
||||
id: c.id,
|
||||
quantidade_por_peca: c.quantidade_por_peca,
|
||||
marca_componente: c.marca_componente,
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Buscar todos os apontamentos de componentes no processo de Corte
|
||||
const { data: todosAptsComp, error: aptsCompError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('peca_id, componente_id, quantidade_produzida')
|
||||
.eq('of_number', ofNumber)
|
||||
.eq('processo_id', processoCorteId)
|
||||
.eq('tipo_apontamento', 'componente');
|
||||
|
||||
if (aptsCompError) {
|
||||
console.error('Erro ao buscar apontamentos de componentes da OF:', aptsCompError);
|
||||
return [];
|
||||
}
|
||||
|
||||
const cortadoPorPecaEComp = new Map<string, Map<string, number>>();
|
||||
(todosAptsComp || []).forEach((a) => {
|
||||
if (a.peca_id && a.componente_id) {
|
||||
if (!cortadoPorPecaEComp.has(a.peca_id)) {
|
||||
cortadoPorPecaEComp.set(a.peca_id, new Map());
|
||||
}
|
||||
const mapComp = cortadoPorPecaEComp.get(a.peca_id)!;
|
||||
mapComp.set(
|
||||
a.componente_id,
|
||||
(mapComp.get(a.componente_id) || 0) + Number(a.quantidade_produzida || 0)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Buscar apontamentos de peças mestres existentes no Corte
|
||||
const { data: todosAptsPeca, error: aptsPecaError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('peca_id, quantidade_produzida')
|
||||
.eq('of_number', ofNumber)
|
||||
.eq('processo_id', processoCorteId)
|
||||
.eq('tipo_apontamento', 'peca');
|
||||
|
||||
if (aptsPecaError) {
|
||||
console.error('Erro ao buscar apontamentos de peça da OF:', aptsPecaError);
|
||||
return [];
|
||||
}
|
||||
|
||||
const jaApontadoPorPeca = new Map<string, number>();
|
||||
(todosAptsPeca || []).forEach((a) => {
|
||||
if (a.peca_id) {
|
||||
jaApontadoPorPeca.set(
|
||||
a.peca_id,
|
||||
(jaApontadoPorPeca.get(a.peca_id) || 0) + Number(a.quantidade_produzida || 0)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Identificar deltas e preparar inserções em lote
|
||||
const hoje = new Date().toISOString().split('T')[0];
|
||||
const insertsNovos: any[] = [];
|
||||
const resultados: ResultadoSincronizacaoPeca[] = [];
|
||||
|
||||
for (const peca of pecasComComponentes) {
|
||||
const comps = compsPorPeca.get(peca.id) || [];
|
||||
if (comps.length === 0) continue;
|
||||
|
||||
const cortados = cortadoPorPecaEComp.get(peca.id) || new Map();
|
||||
const { kitsCompletos } = calcularKitsCompletosPeca(peca.quantidade, comps, cortados);
|
||||
const jaApontado = jaApontadoPorPeca.get(peca.id) || 0;
|
||||
const delta = kitsCompletos - jaApontado;
|
||||
|
||||
if (delta > 0) {
|
||||
insertsNovos.push({
|
||||
of_number: peca.of_number,
|
||||
peca_id: peca.id,
|
||||
processo_id: processoCorteId,
|
||||
tipo_apontamento: 'peca',
|
||||
quantidade_produzida: delta,
|
||||
data_apontamento: hoje,
|
||||
created_by: userId || null,
|
||||
usuario_nome: usuarioNome || 'Sistema (Liberação de Kits)',
|
||||
observacoes: `Liberação automática de Kit 100% cortado (${kitsCompletos}/${peca.quantidade} un)`,
|
||||
status_confirmacao: 'confirmado',
|
||||
is_forcado: false,
|
||||
});
|
||||
}
|
||||
|
||||
resultados.push({
|
||||
peca_id: peca.id,
|
||||
marca: peca.marca,
|
||||
quantidade_total: peca.quantidade,
|
||||
kits_completos: kitsCompletos,
|
||||
ja_apontado_anteriormente: jaApontado,
|
||||
delta_liberado: Math.max(0, delta),
|
||||
});
|
||||
}
|
||||
|
||||
if (insertsNovos.length > 0) {
|
||||
const { error: insertError } = await supabase.from('apontamentos_producao').insert(insertsNovos);
|
||||
if (insertError) {
|
||||
console.error('Erro ao sincronizar peças mestre em lote:', insertError);
|
||||
} else {
|
||||
console.log(`✅ Sincronização de kits concluída: ${insertsNovos.length} peças atualizadas no Corte.`);
|
||||
}
|
||||
}
|
||||
|
||||
return resultados;
|
||||
} catch (err) {
|
||||
console.error('Erro na sincronização geral de kits da OF:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+247
-50
@@ -1,31 +1,54 @@
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { ItemPendenciaProducao } from '@/components/expedicao/PendenciasProducaoModal';
|
||||
|
||||
export interface ProcessoFaltanteItem {
|
||||
processo_id: string;
|
||||
nome: string;
|
||||
ordem: number;
|
||||
qtdFaltante: number;
|
||||
qtdFeita: number;
|
||||
qtdNecessaria: number;
|
||||
}
|
||||
|
||||
export interface PecaAnaliseRomaneio {
|
||||
peca_id: string;
|
||||
marca: string;
|
||||
fase?: string;
|
||||
quantidade_expedida: number;
|
||||
tem_componentes: boolean;
|
||||
processosFaltantes: ProcessoFaltanteItem[];
|
||||
liberada: boolean;
|
||||
qtdJaExpedida: number;
|
||||
}
|
||||
|
||||
export interface ResultadoValidacaoEntrega {
|
||||
valido: boolean;
|
||||
mensagem?: string;
|
||||
pendencias?: ItemPendenciaProducao[];
|
||||
pecasAnalise?: PecaAnaliseRomaneio[];
|
||||
totalItens?: number;
|
||||
totalLiberadas?: number;
|
||||
totalPendentes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida se um romaneio pode ser marcado com o status "Entregue".
|
||||
* Verifica se todas as peças cadastradas no romaneio foram devidamente apontadas
|
||||
* em todos os processos de fabricação anteriores à Expedição (Detalhamento, Corte, Solda - quando aplicável -, Pintura/Galv).
|
||||
* Valida e analisa se um romaneio pode ser marcado com o status "Entregue".
|
||||
* Identifica para cada peça do romaneio se todos os processos anteriores foram concluídos,
|
||||
* e detalha com precisão cirúrgica quais processos estão faltantes.
|
||||
*/
|
||||
export async function validarRomaneioParaEntrega(
|
||||
romaneioId?: string,
|
||||
ofNumber?: string
|
||||
): Promise<ResultadoValidacaoEntrega> {
|
||||
// Se não tem ID do romaneio (está criando um novo romaneio no momento)
|
||||
if (!romaneioId) {
|
||||
return {
|
||||
valido: false,
|
||||
mensagem: 'Não é possível criar um novo romaneio diretamente com o status "Entregue". Crie o romaneio primeiro em planejamento, adicione as peças e garanta que todos os processos anteriores foram apontados na produção.'
|
||||
mensagem: 'Não é possível criar um novo romaneio diretamente com o status "Entregue". Crie o romaneio primeiro em planejamento, adicione as peças e confirme a entrega.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Buscar as peças cadastradas no romaneio
|
||||
// 1. Buscar peças cadastradas no romaneio
|
||||
const { data: itens, error: itensError } = await supabase
|
||||
.from('itens_romaneio_pecas')
|
||||
.select('id, peca_id, marca, quantidade_expedida, fase')
|
||||
@@ -61,10 +84,10 @@ export async function validarRomaneioParaEntrega(
|
||||
};
|
||||
}
|
||||
|
||||
// Encontrar o processo de Expedição (ordem 5 ou nome contendo 'expedi')
|
||||
// Processo de Expedição (ordem 5 ou nome contendo 'expedi')
|
||||
const processoExpedicao = processos.find(p =>
|
||||
p.nome?.toLowerCase().includes('expedi') || p.ordem === 5
|
||||
) || { ordem: 5 };
|
||||
) || { id: '', nome: 'Expedicao', ordem: 5 };
|
||||
|
||||
// Processos anteriores que precisam estar concluídos
|
||||
const processosAnteriores = processos.filter(p => p.ordem < processoExpedicao.ordem);
|
||||
@@ -73,7 +96,7 @@ export async function validarRomaneioParaEntrega(
|
||||
const pecaIds = Array.from(new Set(itens.map(i => i.peca_id).filter(Boolean)));
|
||||
const { data: pecasInfo, error: pecasError } = await supabase
|
||||
.from('pecas')
|
||||
.select('id, marca, tem_componentes')
|
||||
.select('id, marca, tem_componentes, fase')
|
||||
.in('id', pecaIds);
|
||||
|
||||
if (pecasError) {
|
||||
@@ -82,10 +105,10 @@ export async function validarRomaneioParaEntrega(
|
||||
|
||||
const pecasMap = new Map((pecasInfo || []).map(p => [p.id, p]));
|
||||
|
||||
// 4. Buscar apontamentos de produção existentes para essas peças
|
||||
// 4. Buscar apontamentos existentes para essas peças
|
||||
let queryApontamentos = supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('peca_id, processo_id, quantidade_produzida')
|
||||
.select('peca_id, processo_id, quantidade_produzida, tipo_apontamento')
|
||||
.in('peca_id', pecaIds);
|
||||
|
||||
if (ofNumber) {
|
||||
@@ -98,10 +121,13 @@ export async function validarRomaneioParaEntrega(
|
||||
console.error('❌ Erro ao buscar apontamentos:', aptError);
|
||||
}
|
||||
|
||||
// Mapear quantidades apontadas: peca_id -> processo_id -> soma(quantidade_produzida)
|
||||
// Mapear quantidades apontadas por peça mestre: peca_id -> processo_id -> soma(quantidade_produzida)
|
||||
const apontamentosMap = new Map<string, Map<string, number>>();
|
||||
(apontamentos || []).forEach(apt => {
|
||||
// Ignorar apontamento de subcomponente para não inflar a contagem da peça mestre
|
||||
if (apt.tipo_apontamento && apt.tipo_apontamento !== 'peca') return;
|
||||
if (!apt.peca_id || !apt.processo_id) return;
|
||||
|
||||
if (!apontamentosMap.has(apt.peca_id)) {
|
||||
apontamentosMap.set(apt.peca_id, new Map<string, number>());
|
||||
}
|
||||
@@ -110,58 +136,73 @@ export async function validarRomaneioParaEntrega(
|
||||
procMap.set(apt.processo_id, atual + Number(apt.quantidade_produzida || 0));
|
||||
});
|
||||
|
||||
// 5. Consolidar quantidade total exigida por peça no romaneio
|
||||
const somaQtdRomaneioPorPeca = new Map<string, { marca: string; totalQtd: number }>();
|
||||
itens.forEach(item => {
|
||||
const atual = somaQtdRomaneioPorPeca.get(item.peca_id) || {
|
||||
marca: item.marca,
|
||||
totalQtd: 0
|
||||
};
|
||||
atual.totalQtd += Number(item.quantidade_expedida || 0);
|
||||
somaQtdRomaneioPorPeca.set(item.peca_id, atual);
|
||||
});
|
||||
|
||||
// 6. Verificar conformidade em todos os processos anteriores
|
||||
// 5. Analisar cada item do romaneio
|
||||
const pendencias: ItemPendenciaProducao[] = [];
|
||||
const pecasAnalise: PecaAnaliseRomaneio[] = [];
|
||||
|
||||
for (const [pecaId, { marca, totalQtd }] of somaQtdRomaneioPorPeca.entries()) {
|
||||
const peca = pecasMap.get(pecaId);
|
||||
const temComponentes = peca?.tem_componentes ?? false;
|
||||
const procMap = apontamentosMap.get(pecaId) || new Map<string, number>();
|
||||
itens.forEach(item => {
|
||||
const peca = pecasMap.get(item.peca_id);
|
||||
const temComp = peca?.tem_componentes ?? false;
|
||||
const procMap = apontamentosMap.get(item.peca_id) || new Map<string, number>();
|
||||
const qtdRomaneio = Number(item.quantidade_expedida || 0);
|
||||
|
||||
for (const processo of processosAnteriores) {
|
||||
// Se for Solda e a peça NÃO tem componentes (peça solta), ela não passa por solda
|
||||
const isSolda = processo.ordem === 3 || processo.nome.toLowerCase().includes('solda');
|
||||
if (isSolda && !temComponentes) {
|
||||
const processosFaltantes: ProcessoFaltanteItem[] = [];
|
||||
|
||||
for (const proc of processosAnteriores) {
|
||||
// Se for Solda e a peça NÃO tem componentes (peça simples sem montagem), não passa por solda
|
||||
const isSolda = proc.ordem === 3 || proc.nome.toLowerCase().includes('solda');
|
||||
if (isSolda && !temComp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const qtdApontada = procMap.get(processo.id) || 0;
|
||||
const qtdFeita = procMap.get(proc.id) || 0;
|
||||
if (qtdFeita < qtdRomaneio) {
|
||||
const qtdFaltante = Math.max(0, qtdRomaneio - qtdFeita);
|
||||
processosFaltantes.push({
|
||||
processo_id: proc.id,
|
||||
nome: proc.nome,
|
||||
ordem: proc.ordem,
|
||||
qtdNecessaria: qtdRomaneio,
|
||||
qtdFeita,
|
||||
qtdFaltante
|
||||
});
|
||||
|
||||
if (qtdApontada < totalQtd) {
|
||||
pendencias.push({
|
||||
peca_id: pecaId,
|
||||
marca: marca || peca?.marca || 'Peça',
|
||||
processo: processo.nome,
|
||||
ordem: processo.ordem,
|
||||
qtdNecessaria: totalQtd,
|
||||
qtdApontada,
|
||||
qtdFaltante: Math.max(0, totalQtd - qtdApontada)
|
||||
peca_id: item.peca_id,
|
||||
marca: item.marca || peca?.marca || 'Peça',
|
||||
processo: proc.nome,
|
||||
ordem: proc.ordem,
|
||||
qtdNecessaria: qtdRomaneio,
|
||||
qtdApontada: qtdFeita,
|
||||
qtdFaltante
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pendencias.length > 0) {
|
||||
console.warn(`⚠️ Romaneio ${romaneioId} possui ${pendencias.length} pendência(s) de processos anteriores:`, pendencias);
|
||||
return {
|
||||
valido: false,
|
||||
pendencias
|
||||
};
|
||||
}
|
||||
const qtdJaExpedida = procMap.get(processoExpedicao.id) || 0;
|
||||
|
||||
pecasAnalise.push({
|
||||
peca_id: item.peca_id,
|
||||
marca: item.marca || peca?.marca || 'Peça',
|
||||
fase: item.fase || peca?.fase || '-',
|
||||
quantidade_expedida: qtdRomaneio,
|
||||
tem_componentes: temComp,
|
||||
processosFaltantes,
|
||||
liberada: processosFaltantes.length === 0,
|
||||
qtdJaExpedida
|
||||
});
|
||||
});
|
||||
|
||||
const totalLiberadas = pecasAnalise.filter(p => p.liberada).length;
|
||||
const totalPendentes = pecasAnalise.filter(p => !p.liberada).length;
|
||||
|
||||
return {
|
||||
valido: true
|
||||
valido: pendencias.length === 0,
|
||||
pendencias,
|
||||
pecasAnalise,
|
||||
totalItens: pecasAnalise.length,
|
||||
totalLiberadas,
|
||||
totalPendentes
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error('❌ Exceção ao validar romaneio para entrega:', err);
|
||||
@@ -171,3 +212,159 @@ export async function validarRomaneioParaEntrega(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa o apontamento automático para o romaneio entregue:
|
||||
* - Grava na Data Real informada pelo usuário.
|
||||
* - Suporta apontamento em cascata nos processos anteriores pendentes (se autorizado pelo usuário).
|
||||
* - Registra apontamento oficial no processo de Expedição.
|
||||
* - Atualiza o status do romaneio para "Entregue".
|
||||
*/
|
||||
export async function executarApontamentoRomaneioEntregue({
|
||||
romaneioId,
|
||||
ofNumber,
|
||||
numeroRomaneio,
|
||||
dataEntregaReal,
|
||||
autorizarCascata = false,
|
||||
apontarApenasLiberadas = false,
|
||||
userId
|
||||
}: {
|
||||
romaneioId: string;
|
||||
ofNumber: string;
|
||||
numeroRomaneio: string;
|
||||
dataEntregaReal: string;
|
||||
autorizarCascata?: boolean;
|
||||
apontarApenasLiberadas?: boolean;
|
||||
userId?: string;
|
||||
}): Promise<{ sucesso: boolean; mensagem: string; totalApontamentos: number; pecasProcessadas: string[] }> {
|
||||
try {
|
||||
const analise = await validarRomaneioParaEntrega(romaneioId, ofNumber);
|
||||
if (!analise.pecasAnalise || analise.pecasAnalise.length === 0) {
|
||||
throw new Error(analise.mensagem || 'Nenhuma peça encontrada no romaneio');
|
||||
}
|
||||
|
||||
// Buscar processo de Expedição
|
||||
const { data: processos } = await supabase
|
||||
.from('processos_fabricacao')
|
||||
.select('id, nome, ordem')
|
||||
.eq('ativo', true)
|
||||
.order('ordem');
|
||||
|
||||
const processoExpedicao = processos?.find(p =>
|
||||
p.nome?.toLowerCase().includes('expedi') || p.ordem === 5
|
||||
);
|
||||
|
||||
if (!processoExpedicao) {
|
||||
throw new Error('Processo Expedição não encontrado');
|
||||
}
|
||||
|
||||
const apontamentosParaInserir: any[] = [];
|
||||
const pecasProcessadas: string[] = [];
|
||||
const obsOrigem = `Apontamento via Romaneio ${numeroRomaneio} (ID: ${romaneioId})`;
|
||||
|
||||
for (const peca of analise.pecasAnalise) {
|
||||
// Cenário 1: Peça com processos anteriores pendentes
|
||||
if (!peca.liberada) {
|
||||
if (autorizarCascata) {
|
||||
// Criar apontamento para cada processo faltante
|
||||
for (const faltante of peca.processosFaltantes) {
|
||||
apontamentosParaInserir.push({
|
||||
of_number: ofNumber,
|
||||
peca_id: peca.peca_id,
|
||||
processo_id: faltante.processo_id,
|
||||
quantidade_produzida: faltante.qtdFaltante,
|
||||
data_apontamento: dataEntregaReal,
|
||||
observacoes: `Apontamento em cascata (${faltante.nome}) - ${obsOrigem}`,
|
||||
tipo_apontamento: 'peca',
|
||||
created_by: userId
|
||||
});
|
||||
}
|
||||
|
||||
// Criar apontamento de Expedição
|
||||
apontamentosParaInserir.push({
|
||||
of_number: ofNumber,
|
||||
peca_id: peca.peca_id,
|
||||
processo_id: processoExpedicao.id,
|
||||
quantidade_produzida: peca.quantidade_expedida,
|
||||
data_apontamento: dataEntregaReal,
|
||||
observacoes: `Apontamento de expedição - ${obsOrigem}`,
|
||||
tipo_apontamento: 'peca',
|
||||
created_by: userId
|
||||
});
|
||||
|
||||
pecasProcessadas.push(`${peca.marca} (${peca.quantidade_expedida} un - com cascata)`);
|
||||
} else if (apontarApenasLiberadas) {
|
||||
// Pular peça pendente
|
||||
continue;
|
||||
} else {
|
||||
// Não autorizado e não pediu para apontar parciais
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Cenário 2: Peça 100% liberada nos processos anteriores
|
||||
apontamentosParaInserir.push({
|
||||
of_number: ofNumber,
|
||||
peca_id: peca.peca_id,
|
||||
processo_id: processoExpedicao.id,
|
||||
quantidade_produzida: peca.quantidade_expedida,
|
||||
data_apontamento: dataEntregaReal,
|
||||
observacoes: `Apontamento de expedição - ${obsOrigem}`,
|
||||
tipo_apontamento: 'peca',
|
||||
created_by: userId
|
||||
});
|
||||
|
||||
pecasProcessadas.push(`${peca.marca} (${peca.quantidade_expedida} un)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (apontamentosParaInserir.length === 0) {
|
||||
return {
|
||||
sucesso: false,
|
||||
mensagem: 'Nenhum apontamento foi inserido pois não houve peças autorizadas para expedição.',
|
||||
totalApontamentos: 0,
|
||||
pecasProcessadas: []
|
||||
};
|
||||
}
|
||||
|
||||
// Inserir os apontamentos na tabela oficial
|
||||
const { error: insertError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.insert(apontamentosParaInserir);
|
||||
|
||||
if (insertError) {
|
||||
console.error('❌ Erro ao inserir apontamentos:', insertError);
|
||||
throw new Error(`Falha ao registrar apontamentos: ${insertError.message}`);
|
||||
}
|
||||
|
||||
// Atualizar o status do romaneio para "Entregue"
|
||||
await supabase
|
||||
.from('romaneios_expedicao')
|
||||
.update({
|
||||
status: 'Entregue',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', romaneioId);
|
||||
|
||||
// Registrar log em logs_apontamentos_automaticos
|
||||
await supabase
|
||||
.from('logs_apontamentos_automaticos')
|
||||
.insert({
|
||||
romaneio_id: romaneioId,
|
||||
numero_romaneio: numeroRomaneio,
|
||||
of_number: ofNumber,
|
||||
total_pecas_processadas: pecasProcessadas.length,
|
||||
status_operacao: 'sucesso',
|
||||
usuario_executou: userId || null
|
||||
});
|
||||
|
||||
return {
|
||||
sucesso: true,
|
||||
mensagem: `${apontamentosParaInserir.length} apontamento(s) realizado(s) com sucesso na data ${dataEntregaReal}!`,
|
||||
totalApontamentos: apontamentosParaInserir.length,
|
||||
pecasProcessadas
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error('❌ Erro na execução de apontamentos do romaneio:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user