feat: forcar apontamento modo smart, notificacoes hoje/ontem e alertas visuais 0.5s
This commit is contained in:
@@ -45,16 +45,30 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{getMarcaItem(apontamento)}
|
{getMarcaItem(apontamento)}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{apontamento.of_number || 'N/A'}
|
{apontamento.of_number || 'N/A'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{apontamento.is_forcado && (
|
||||||
|
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full border ${
|
||||||
|
apontamento.status_confirmacao === 'pendente_confirmacao'
|
||||||
|
? 'bg-red-100 text-red-700 border-red-300 animate-pulse dark:bg-red-950/80 dark:text-red-400 dark:border-red-800'
|
||||||
|
: 'bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/80 dark:text-amber-400 dark:border-amber-800'
|
||||||
|
}`}>
|
||||||
|
⚡ FORÇADO {apontamento.status_confirmacao === 'pendente_confirmacao' ? '(Pendente)' : '(Confirmado)'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||||
{apontamento.processo?.nome || 'N/A'} • {formatDate(apontamento.data_apontamento)}
|
<span>{apontamento.processo?.nome || 'N/A'} • {formatDate(apontamento.data_apontamento)}</span>
|
||||||
|
{(apontamento.usuario_nome || apontamento.forcado_por_user_nome) && (
|
||||||
|
<span className="font-semibold text-slate-700 dark:text-slate-300">
|
||||||
|
• Por: {apontamento.forcado_por_user_nome || apontamento.usuario_nome}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ export interface ApontamentoProducao {
|
|||||||
observacoes?: string;
|
observacoes?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
|
usuario_nome?: string;
|
||||||
|
is_forcado?: boolean;
|
||||||
|
status_confirmacao?: 'confirmado' | 'pendente_confirmacao' | string;
|
||||||
|
forcado_por_user_nome?: string;
|
||||||
|
forcado_por_user_id?: string;
|
||||||
|
forcado_para_processo_id?: string;
|
||||||
|
data_confirmacao?: string;
|
||||||
|
confirmado_por_user_nome?: string;
|
||||||
|
confirmado_por_user_id?: string;
|
||||||
peca?: {
|
peca?: {
|
||||||
marca: string;
|
marca: string;
|
||||||
descricao: string;
|
descricao: string;
|
||||||
|
|||||||
@@ -212,3 +212,22 @@
|
|||||||
opacity: 0.15;
|
opacity: 0.15;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-red-fast {
|
||||||
|
0%, 100% {
|
||||||
|
border-color: rgba(239, 68, 68, 1);
|
||||||
|
background-color: rgba(239, 68, 68, 0.18);
|
||||||
|
box-shadow: 0 0 16px rgba(239, 68, 68, 0.65);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
background-color: rgba(239, 68, 68, 0.04);
|
||||||
|
box-shadow: 0 0 4px rgba(239, 68, 68, 0.15);
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-red-fast {
|
||||||
|
animation: pulse-red-fast 0.5s ease-in-out infinite !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { ProcessoFabricacao } from '@/hooks/useProcessosFabricacao';
|
||||||
|
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
X,
|
||||||
|
Zap,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
ArrowRight,
|
||||||
|
Sparkles,
|
||||||
|
RefreshCw,
|
||||||
|
Scissors,
|
||||||
|
Ruler,
|
||||||
|
Puzzle,
|
||||||
|
Paintbrush,
|
||||||
|
ShieldAlert
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface PecaData {
|
||||||
|
id: string;
|
||||||
|
marca: string;
|
||||||
|
descricao: string;
|
||||||
|
etapa_fase: string;
|
||||||
|
quantidade: number;
|
||||||
|
peso_unitario: number;
|
||||||
|
perfil_principal?: string;
|
||||||
|
tem_componentes?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SmartForcarApontamentoModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
obra: OFAtiva;
|
||||||
|
faseSelecionada: string;
|
||||||
|
processoAtual: ProcessoFabricacao;
|
||||||
|
todosProcessos: ProcessoFabricacao[];
|
||||||
|
pecas: PecaData[];
|
||||||
|
mapaProducao: Map<string, number>;
|
||||||
|
onSucesso: (pecasCount: number, pesoKg: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartForcarApontamentoModal: React.FC<SmartForcarApontamentoModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
obra,
|
||||||
|
faseSelecionada,
|
||||||
|
processoAtual,
|
||||||
|
todosProcessos,
|
||||||
|
pecas,
|
||||||
|
mapaProducao,
|
||||||
|
onSucesso,
|
||||||
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [pecaSelecionada, setPecaSelecionada] = useState<PecaData | null>(null);
|
||||||
|
const [qtdForcar, setQtdForcar] = useState<number>(1);
|
||||||
|
const [salvando, setSalvando] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const getIconeProcesso = (nome: string) => {
|
||||||
|
const n = (nome || '').toLowerCase();
|
||||||
|
if (n.includes('corte')) return <Scissors className="h-4 w-4" />;
|
||||||
|
if (n.includes('dobra') || n.includes('fura')) return <Ruler className="h-4 w-4" />;
|
||||||
|
if (n.includes('montag')) return <Puzzle className="h-4 w-4" />;
|
||||||
|
if (n.includes('solda')) return <Zap className="h-4 w-4" />;
|
||||||
|
if (n.includes('pintura')) return <Paintbrush className="h-4 w-4" />;
|
||||||
|
return <ShieldAlert className="h-4 w-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encontra o processo anterior para uma determinada peça considerando regra S/M
|
||||||
|
const getProcessoAnteriorPeca = (peca: PecaData): ProcessoFabricacao | null => {
|
||||||
|
const procsOrdenados = [...todosProcessos].sort((a, b) => a.ordem - b.ordem);
|
||||||
|
const indexAtual = procsOrdenados.findIndex((p) => p.id === processoAtual.id);
|
||||||
|
if (indexAtual <= 0) return null;
|
||||||
|
|
||||||
|
const ehSemMontagem = peca.tem_componentes === false;
|
||||||
|
|
||||||
|
for (let i = indexAtual - 1; i >= 0; i--) {
|
||||||
|
const proc = procsOrdenados[i];
|
||||||
|
const nomeP = (proc.nome || '').toLowerCase();
|
||||||
|
// Peças sem montagem (S/M) pulam solda
|
||||||
|
if (ehSemMontagem && nomeP.includes('solda')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return proc;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Peças da fase que ainda NÃO foram totalmente apontadas no processo anterior
|
||||||
|
const pecasPendentesProcessoAnterior = useMemo(() => {
|
||||||
|
return pecas
|
||||||
|
.filter((p) => {
|
||||||
|
if (!faseSelecionada || faseSelecionada === 'Geral') return true;
|
||||||
|
return (p.etapa_fase || 'Geral').trim() === faseSelecionada;
|
||||||
|
})
|
||||||
|
.map((p) => {
|
||||||
|
const procAnterior = getProcessoAnteriorPeca(p);
|
||||||
|
if (!procAnterior) {
|
||||||
|
return { ...p, procAnterior: null, saldoPendenteAnterior: 0, jaApontadoAnterior: 0 };
|
||||||
|
}
|
||||||
|
const keyAnt = `${p.id}_${procAnterior.id}`;
|
||||||
|
const jaApontadoAnterior = mapaProducao.get(keyAnt) || 0;
|
||||||
|
const saldoPendenteAnterior = Math.max(0, p.quantidade - jaApontadoAnterior);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
procAnterior,
|
||||||
|
saldoPendenteAnterior,
|
||||||
|
jaApontadoAnterior,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((p) => p.procAnterior !== null && p.saldoPendenteAnterior > 0)
|
||||||
|
.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }));
|
||||||
|
}, [pecas, faseSelecionada, processoAtual, todosProcessos, mapaProducao]);
|
||||||
|
|
||||||
|
const handleSelecionarPeca = (peca: any) => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setPecaSelecionada(peca);
|
||||||
|
setQtdForcar(peca.saldoPendenteAnterior > 0 ? (peca.saldoPendenteAnterior >= 1 ? 1 : peca.saldoPendenteAnterior) : 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmarForcamento = async () => {
|
||||||
|
if (!pecaSelecionada || !user) return;
|
||||||
|
|
||||||
|
const procAnterior = getProcessoAnteriorPeca(pecaSelecionada);
|
||||||
|
if (!procAnterior) {
|
||||||
|
toast.error('Processo anterior não identificado para esta peça');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qtdForcar <= 0) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
toast.error('Informe uma quantidade válida');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSalvando(true);
|
||||||
|
const hoje = new Date().toISOString().split('T')[0];
|
||||||
|
const nomeUsuario = user.name || user.username || user.email?.split('@')[0] || 'Operador Chão de Fábrica';
|
||||||
|
|
||||||
|
// 1. Inserir apontamento no Processo Anterior com flag de FORÇADO e PENDENTE DE CONFIRMAÇÃO
|
||||||
|
const payloadAnterior = {
|
||||||
|
of_number: obra.of_number,
|
||||||
|
peca_id: pecaSelecionada.id,
|
||||||
|
tipo_apontamento: 'peca' as const,
|
||||||
|
processo_id: procAnterior.id,
|
||||||
|
quantidade_produzida: qtdForcar,
|
||||||
|
data_apontamento: hoje,
|
||||||
|
created_by: user.id,
|
||||||
|
usuario_nome: nomeUsuario,
|
||||||
|
is_forcado: true,
|
||||||
|
status_confirmacao: 'pendente_confirmacao',
|
||||||
|
forcado_por_user_nome: nomeUsuario,
|
||||||
|
forcado_por_user_id: user.id,
|
||||||
|
forcado_para_processo_id: processoAtual.id,
|
||||||
|
observacoes: `Forçado por ${nomeUsuario} no posto ${processoAtual.nome}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Inserir apontamento no Processo Atual (pois o operador já produziu/está com as peças)
|
||||||
|
const payloadAtual = {
|
||||||
|
of_number: obra.of_number,
|
||||||
|
peca_id: pecaSelecionada.id,
|
||||||
|
tipo_apontamento: 'peca' as const,
|
||||||
|
processo_id: processoAtual.id,
|
||||||
|
quantidade_produzida: qtdForcar,
|
||||||
|
data_apontamento: hoje,
|
||||||
|
created_by: user.id,
|
||||||
|
usuario_nome: nomeUsuario,
|
||||||
|
is_forcado: false,
|
||||||
|
status_confirmacao: 'confirmado',
|
||||||
|
observacoes: `Apontamento regular decorrente de força de ${procAnterior.nome}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error: erroAnt } = await supabase.from('apontamentos_producao').insert(payloadAnterior);
|
||||||
|
if (erroAnt) throw erroAnt;
|
||||||
|
|
||||||
|
const { error: erroAtual } = await supabase.from('apontamentos_producao').insert(payloadAtual);
|
||||||
|
if (erroAtual) throw erroAtual;
|
||||||
|
|
||||||
|
// Sucesso!
|
||||||
|
smartAudio.playSuccess();
|
||||||
|
toast.success(
|
||||||
|
`⚡ Apontamento forçado com sucesso! ${qtdForcar}x ${pecaSelecionada.marca} liberadas de ${procAnterior.nome} e apontadas em ${processoAtual.nome}.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const pesoTotal = qtdForcar * (pecaSelecionada.peso_unitario || 0);
|
||||||
|
onSucesso(qtdForcar, pesoTotal);
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
console.error('Erro ao forçar apontamento:', e);
|
||||||
|
toast.error('Falha ao forçar apontamento: ' + (e?.message || 'Erro de conexão'));
|
||||||
|
} finally {
|
||||||
|
setSalvando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-3 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg max-h-[90vh] flex flex-col bg-white dark:bg-slate-900 rounded-3xl border-2 border-amber-500/50 dark:border-amber-500/40 shadow-2xl overflow-hidden select-none animate-in zoom-in-95 duration-200"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Cabeçalho */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 bg-amber-500/10 dark:bg-amber-950/40 border-b border-amber-500/20">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-amber-500 text-slate-950 flex items-center justify-center font-black shadow-md">
|
||||||
|
<Zap className="w-5 h-5 fill-current animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-sm sm:text-base font-black uppercase tracking-wider text-slate-900 dark:text-white">
|
||||||
|
Forçar Apontamento
|
||||||
|
</h2>
|
||||||
|
<span className="text-[10px] font-black px-2 py-0.5 rounded-full bg-amber-500 text-slate-950">
|
||||||
|
{processoAtual.nome}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-600 dark:text-slate-400">
|
||||||
|
Aponte peças esquecidas no processo anterior para liberar seu posto
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-xl text-slate-500 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aviso de Destaque */}
|
||||||
|
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/20 border-b border-amber-200 dark:border-amber-900/40 flex items-start gap-2.5 text-xs text-amber-800 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span>
|
||||||
|
Ao forçar o apontamento, a peça será automaticamente registrada no processo anterior (com pendência de confirmação) e apontada no seu processo atual.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conteúdo Principal */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
|
||||||
|
{pecasPendentesProcessoAnterior.length === 0 ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2 text-slate-500">
|
||||||
|
<Sparkles className="w-10 h-10 text-emerald-500 stroke-[1.2]" />
|
||||||
|
<p className="text-sm font-bold text-slate-700 dark:text-slate-200">
|
||||||
|
Tudo em ordem!
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 max-w-xs">
|
||||||
|
Não há peças da fase {faseSelecionada} pendentes no processo anterior. Todas as peças disponíveis já foram apontadas!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : !pecaSelecionada ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs font-black uppercase text-slate-500 dark:text-slate-400 tracking-wider">
|
||||||
|
Selecione a peça que deseja forçar:
|
||||||
|
</span>
|
||||||
|
{pecasPendentesProcessoAnterior.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelecionarPeca(item)}
|
||||||
|
className="w-full p-3.5 rounded-2xl bg-white dark:bg-slate-800/90 hover:bg-amber-50/70 dark:hover:bg-slate-750 border-2 border-slate-200 hover:border-amber-400 dark:border-slate-700 dark:hover:border-amber-500 text-left transition-all active:scale-[0.98] flex items-center justify-between gap-3 shadow-sm group"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-mono font-black text-sm text-slate-900 dark:text-slate-100">
|
||||||
|
{item.marca}
|
||||||
|
</span>
|
||||||
|
{item.tem_componentes === false && (
|
||||||
|
<span className="text-[9px] px-1.5 py-0.2 rounded bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300 font-bold">
|
||||||
|
S/M
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300">
|
||||||
|
Total: {item.quantidade} un
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
|
||||||
|
{item.descricao || item.perfil_principal || 'Peça Estrutural'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-amber-700 dark:text-amber-400 font-bold">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{item.procAnterior && getIconeProcesso(item.procAnterior.nome)}
|
||||||
|
Falta em: {item.procAnterior?.nome} ({item.saldoPendenteAnterior} un)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span className="px-3 py-1.5 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 font-black text-xs uppercase shadow-sm group-hover:scale-105 transition-transform flex items-center gap-1">
|
||||||
|
<span>Escolher</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Card da Peça Selecionada */}
|
||||||
|
<div className="p-4 rounded-2xl bg-amber-50/80 dark:bg-amber-950/30 border-2 border-amber-400/80 dark:border-amber-500/50 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono font-black text-lg text-amber-800 dark:text-amber-300">
|
||||||
|
{pecaSelecionada.marca}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setPecaSelecionada(null);
|
||||||
|
}}
|
||||||
|
className="text-xs font-bold text-amber-700 dark:text-amber-400 underline hover:text-amber-900"
|
||||||
|
>
|
||||||
|
Trocar Peça
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-600 dark:text-slate-300">
|
||||||
|
{pecaSelecionada.descricao || pecaSelecionada.perfil_principal}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs font-semibold text-slate-700 dark:text-slate-300 pt-2 border-t border-amber-300/60 dark:border-amber-800/60">
|
||||||
|
<span>Total da Peça: {pecaSelecionada.quantidade} un</span>
|
||||||
|
<span className="text-amber-700 dark:text-amber-400 font-bold">
|
||||||
|
Pendente no anterior: {(pecaSelecionada as any).saldoPendenteAnterior} un
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seletor de Quantidade */}
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<span className="text-xs font-black uppercase text-slate-600 dark:text-slate-300 tracking-wider">
|
||||||
|
Quantas unidades você está forçando agora?
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdForcar((prev) => Math.max(1, prev - 1));
|
||||||
|
}}
|
||||||
|
disabled={qtdForcar <= 1}
|
||||||
|
className="h-14 w-14 rounded-2xl bg-white dark:bg-slate-800 border-2 border-slate-200 dark:border-slate-700 disabled:opacity-40 text-slate-800 dark:text-white flex items-center justify-center font-black active:scale-95 shadow-sm"
|
||||||
|
>
|
||||||
|
<Minus className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-28 h-14 rounded-2xl bg-white dark:bg-slate-900 border-2 border-amber-500 flex items-center justify-center shadow-inner">
|
||||||
|
<span className="text-3xl font-black text-amber-600 dark:text-amber-400">
|
||||||
|
{qtdForcar}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdForcar((prev) => {
|
||||||
|
const max = (pecaSelecionada as any).saldoPendenteAnterior || 1;
|
||||||
|
return Math.min(max, prev + 1);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={qtdForcar >= ((pecaSelecionada as any).saldoPendenteAnterior || 1)}
|
||||||
|
className="h-14 w-14 rounded-2xl bg-white dark:bg-slate-800 border-2 border-slate-200 dark:border-slate-700 disabled:opacity-40 text-slate-800 dark:text-white flex items-center justify-center font-black active:scale-95 shadow-sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdForcar((pecaSelecionada as any).saldoPendenteAnterior || 1);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 rounded-xl bg-amber-100 hover:bg-amber-200 text-amber-800 dark:bg-amber-950 dark:text-amber-300 text-xs font-bold transition-colors"
|
||||||
|
>
|
||||||
|
Máximo Disponível ({(pecaSelecionada as any).saldoPendenteAnterior || 1})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botão de Confirmação */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={salvando || qtdForcar <= 0}
|
||||||
|
onClick={handleConfirmarForcamento}
|
||||||
|
className="w-full h-14 bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 text-sm font-black rounded-2xl shadow-lg shadow-amber-950/20 uppercase tracking-wide active:scale-98 gap-2"
|
||||||
|
>
|
||||||
|
{salvando ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin" />
|
||||||
|
<span>Registrando Forçamento...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="h-5 w-5 stroke-[3]" />
|
||||||
|
<span>CONFIRMAR FORÇAMENTO ({qtdForcar} UN)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rodapé */}
|
||||||
|
<div className="p-3 bg-slate-50 dark:bg-slate-950 border-t border-slate-200 dark:border-slate-800 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="h-10 px-4 rounded-xl text-xs font-bold"
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -25,7 +25,7 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [soundOn, setSoundOn] = useState<boolean>(() => smartAudio.isSoundEnabled());
|
const [soundOn, setSoundOn] = useState<boolean>(() => smartAudio.isSoundEnabled());
|
||||||
const [isNotificationsOpen, setIsNotificationsOpen] = useState<boolean>(false);
|
const [isNotificationsOpen, setIsNotificationsOpen] = useState<boolean>(false);
|
||||||
const { unreadCount, hasNewAlert, clearAlert } = useSmartNotificationsAlert();
|
const { unreadCount, hasNewAlert, hasPendingForced, clearAlert, refetch } = useSmartNotificationsAlert();
|
||||||
|
|
||||||
// Detecta se está efetivamente em modo escuro
|
// Detecta se está efetivamente em modo escuro
|
||||||
const isDark =
|
const isDark =
|
||||||
@@ -140,20 +140,27 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
|
|||||||
setIsNotificationsOpen(true);
|
setIsNotificationsOpen(true);
|
||||||
}}
|
}}
|
||||||
className={`relative h-10 w-8 sm:w-9 rounded-xl flex items-center justify-center border transition-all active:scale-90 ${
|
className={`relative h-10 w-8 sm:w-9 rounded-xl flex items-center justify-center border transition-all active:scale-90 ${
|
||||||
hasNewAlert
|
hasPendingForced
|
||||||
|
? 'animate-pulse-red-fast bg-red-100 border-red-500 text-red-700 dark:bg-red-950/80 dark:border-red-500 dark:text-red-400 shadow-md shadow-red-500/30'
|
||||||
|
: hasNewAlert
|
||||||
? 'bg-amber-100 border-amber-300 text-amber-700 dark:bg-amber-950/70 dark:border-amber-600/50 dark:text-amber-400'
|
? 'bg-amber-100 border-amber-300 text-amber-700 dark:bg-amber-950/70 dark:border-amber-600/50 dark:text-amber-400'
|
||||||
: 'bg-slate-100 border-slate-200 text-slate-600 dark:bg-slate-800/80 dark:border-slate-700 dark:text-slate-300 hover:bg-slate-200'
|
: 'bg-slate-100 border-slate-200 text-slate-600 dark:bg-slate-800/80 dark:border-slate-700 dark:text-slate-300 hover:bg-slate-200'
|
||||||
}`}
|
}`}
|
||||||
title="Notificações de Apontamentos de Produção"
|
title={hasPendingForced ? '⚠️ Existem apontamentos forçados pendentes de confirmação!' : 'Notificações de Apontamentos de Produção'}
|
||||||
>
|
>
|
||||||
<Bell className={`h-4 w-4 ${hasNewAlert ? 'animate-bounce text-amber-600 dark:text-amber-400' : ''}`} />
|
<Bell className={`h-4 w-4 ${hasPendingForced ? 'animate-bounce text-red-600 dark:text-red-400' : hasNewAlert ? 'animate-bounce text-amber-600 dark:text-amber-400' : ''}`} />
|
||||||
{hasNewAlert && (
|
{hasPendingForced ? (
|
||||||
|
<span className="absolute top-1.5 right-1.5 flex h-2.5 w-2.5 pointer-events-none">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-600"></span>
|
||||||
|
</span>
|
||||||
|
) : hasNewAlert ? (
|
||||||
<span className="absolute top-1.5 right-1.5 flex h-2 w-2 pointer-events-none">
|
<span className="absolute top-1.5 right-1.5 flex h-2 w-2 pointer-events-none">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
{unreadCount > 0 && !hasNewAlert && (
|
{unreadCount > 0 && !hasNewAlert && !hasPendingForced && (
|
||||||
<span className="absolute -top-1 -right-1 px-1 min-w-[15px] h-3.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 text-[8px] font-mono font-bold flex items-center justify-center border border-slate-300 dark:border-slate-600 pointer-events-none">
|
<span className="absolute -top-1 -right-1 px-1 min-w-[15px] h-3.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 text-[8px] font-mono font-bold flex items-center justify-center border border-slate-300 dark:border-slate-600 pointer-events-none">
|
||||||
{unreadCount > 99 ? '99+' : unreadCount}
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
@@ -202,6 +209,7 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
|
|||||||
<SmartNotificationsModal
|
<SmartNotificationsModal
|
||||||
isOpen={isNotificationsOpen}
|
isOpen={isNotificationsOpen}
|
||||||
onClose={() => setIsNotificationsOpen(false)}
|
onClose={() => setIsNotificationsOpen(false)}
|
||||||
|
onApontamentoConfirmado={refetch}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { smartAudio } from '@/utils/smartAudio';
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
X,
|
X,
|
||||||
@@ -14,7 +16,9 @@ import {
|
|||||||
Paintbrush,
|
Paintbrush,
|
||||||
Truck,
|
Truck,
|
||||||
Scissors,
|
Scissors,
|
||||||
Box
|
Box,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCheck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export interface ApontamentoNotificacao {
|
export interface ApontamentoNotificacao {
|
||||||
@@ -29,21 +33,28 @@ export interface ApontamentoNotificacao {
|
|||||||
processo_nome: string;
|
processo_nome: string;
|
||||||
processo_cor: string;
|
processo_cor: string;
|
||||||
usuario_nome: string;
|
usuario_nome: string;
|
||||||
|
is_forcado?: boolean;
|
||||||
|
status_confirmacao?: 'confirmado' | 'pendente_confirmacao' | string;
|
||||||
|
forcado_por_user_nome?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SmartNotificationsModalProps {
|
interface SmartNotificationsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
ofFiltrar?: string;
|
ofFiltrar?: string;
|
||||||
|
onApontamentoConfirmado?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = ({
|
export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
ofFiltrar,
|
ofFiltrar,
|
||||||
|
onApontamentoConfirmado,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
const [apontamentos, setApontamentos] = useState<ApontamentoNotificacao[]>([]);
|
const [apontamentos, setApontamentos] = useState<ApontamentoNotificacao[]>([]);
|
||||||
const [carregando, setCarregando] = useState<boolean>(false);
|
const [carregando, setCarregando] = useState<boolean>(false);
|
||||||
|
const [confirmandoId, setConfirmandoId] = useState<string | null>(null);
|
||||||
|
|
||||||
const formatarTempo = (dataIso?: string) => {
|
const formatarTempo = (dataIso?: string) => {
|
||||||
if (!dataIso) return 'Recente';
|
if (!dataIso) return 'Recente';
|
||||||
@@ -76,16 +87,21 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
const carregarNotificacoes = useCallback(async () => {
|
const carregarNotificacoes = useCallback(async () => {
|
||||||
setCarregando(true);
|
setCarregando(true);
|
||||||
try {
|
try {
|
||||||
// 1. Buscar os últimos 30 apontamentos
|
// Filtrar APENAS apontamentos de HOJE e do DIA ANTERIOR (ONTEM)
|
||||||
|
const dataOntem = new Date(Date.now() - 86400000);
|
||||||
|
const dataOntemIso = dataOntem.toISOString().split('T')[0];
|
||||||
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from('apontamentos_producao' as any)
|
.from('apontamentos_producao' as any)
|
||||||
.select(`
|
.select(`
|
||||||
id, of_number, quantidade_produzida, data_apontamento, created_at, created_by,
|
id, of_number, quantidade_produzida, data_apontamento, created_at, created_by,
|
||||||
|
is_forcado, status_confirmacao, forcado_por_user_nome, usuario_nome,
|
||||||
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase, perfil_principal, descricao),
|
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase, perfil_principal, descricao),
|
||||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
|
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
|
||||||
`)
|
`)
|
||||||
|
.gte('data_apontamento', dataOntemIso)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(30);
|
.limit(100);
|
||||||
|
|
||||||
if (ofFiltrar) {
|
if (ofFiltrar) {
|
||||||
query = query.eq('of_number', ofFiltrar);
|
query = query.eq('of_number', ofFiltrar);
|
||||||
@@ -94,7 +110,7 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
const { data: apontamentosData, error } = await query;
|
const { data: apontamentosData, error } = await query;
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
// 2. Buscar nomes dos usuários dos apontamentos
|
// Buscar nomes dos usuários dos apontamentos caso não estejam gravados diretamente
|
||||||
const userIds = Array.from(new Set((apontamentosData || []).map((ap: any) => ap.created_by).filter(Boolean)));
|
const userIds = Array.from(new Set((apontamentosData || []).map((ap: any) => ap.created_by).filter(Boolean)));
|
||||||
const profilesMap = new Map<string, string>();
|
const profilesMap = new Map<string, string>();
|
||||||
|
|
||||||
@@ -116,11 +132,11 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Montar lista tratada
|
// Montar lista tratada
|
||||||
const lista: ApontamentoNotificacao[] = (apontamentosData || []).map((ap: any) => {
|
const lista: ApontamentoNotificacao[] = (apontamentosData || []).map((ap: any) => {
|
||||||
const peca = ap.peca || {};
|
const peca = ap.peca || {};
|
||||||
const processo = ap.processo || {};
|
const processo = ap.processo || {};
|
||||||
const userName = (ap.created_by && profilesMap.get(ap.created_by)) || 'Chão de Fábrica';
|
const userName = ap.usuario_nome || (ap.created_by && profilesMap.get(ap.created_by)) || 'Chão de Fábrica';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(ap.id),
|
id: String(ap.id),
|
||||||
@@ -134,6 +150,9 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
processo_nome: processo.nome || 'Produção',
|
processo_nome: processo.nome || 'Produção',
|
||||||
processo_cor: processo.cor || '#10b981',
|
processo_cor: processo.cor || '#10b981',
|
||||||
usuario_nome: userName,
|
usuario_nome: userName,
|
||||||
|
is_forcado: Boolean(ap.is_forcado),
|
||||||
|
status_confirmacao: ap.status_confirmacao || 'confirmado',
|
||||||
|
forcado_por_user_nome: ap.forcado_por_user_nome,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,6 +164,45 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
}
|
}
|
||||||
}, [ofFiltrar]);
|
}, [ofFiltrar]);
|
||||||
|
|
||||||
|
const handleConfirmarBaixaForcada = async (item: ApontamentoNotificacao) => {
|
||||||
|
try {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setConfirmandoId(item.id);
|
||||||
|
|
||||||
|
const nomeOperador = user?.name || user?.email?.split('@')[0] || 'Operador';
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('apontamentos_producao' as any)
|
||||||
|
.update({
|
||||||
|
status_confirmacao: 'confirmado',
|
||||||
|
data_confirmacao: new Date().toISOString(),
|
||||||
|
confirmado_por_user_nome: nomeOperador,
|
||||||
|
confirmado_por_user_id: user?.id,
|
||||||
|
})
|
||||||
|
.eq('id', item.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
smartAudio.playSuccess();
|
||||||
|
toast.success(`✅ Baixa confirmada: ${item.marca} (${item.processo_nome})`);
|
||||||
|
|
||||||
|
// Atualiza lista local
|
||||||
|
setApontamentos((prev) =>
|
||||||
|
prev.map((ap) => (ap.id === item.id ? { ...ap, status_confirmacao: 'confirmado' } : ap))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onApontamentoConfirmado) {
|
||||||
|
onApontamentoConfirmado();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
console.error('Erro ao confirmar baixa:', err);
|
||||||
|
toast.error('Erro ao confirmar baixa: ' + (err?.message || 'Falha de conexão'));
|
||||||
|
} finally {
|
||||||
|
setConfirmandoId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
carregarNotificacoes();
|
carregarNotificacoes();
|
||||||
@@ -153,6 +211,13 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const pendentesConfirmacao = apontamentos.filter(
|
||||||
|
(ap) => ap.is_forcado && ap.status_confirmacao === 'pendente_confirmacao'
|
||||||
|
);
|
||||||
|
const outrosApontamentos = apontamentos.filter(
|
||||||
|
(ap) => !(ap.is_forcado && ap.status_confirmacao === 'pendente_confirmacao')
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
|
className="fixed inset-0 z-50 flex items-center justify-center p-3 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
|
||||||
@@ -178,7 +243,7 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[11px] text-slate-500 dark:text-slate-400">
|
<p className="text-[11px] text-slate-500 dark:text-slate-400">
|
||||||
Histórico em tempo real das peças produzidas
|
Mostrando apontamentos de <strong className="text-slate-700 dark:text-slate-300">hoje e ontem</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,22 +275,94 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lista de Notificações de Apontamentos */}
|
{/* Conteúdo com Apontamentos */}
|
||||||
<div className="flex-1 overflow-y-auto p-3 sm:p-4 space-y-2.5 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto p-3 sm:p-4 space-y-3 custom-scrollbar">
|
||||||
|
{/* Seção 1: Apontamentos Forçados Pendentes de Confirmação */}
|
||||||
|
{pendentesConfirmacao.length > 0 && (
|
||||||
|
<div className="p-3 rounded-2xl bg-red-50/80 dark:bg-red-950/40 border-2 border-red-300 dark:border-red-800/80 space-y-2.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-black uppercase tracking-wide text-red-700 dark:text-red-400">
|
||||||
|
<AlertTriangle className="w-4 h-4 animate-bounce shrink-0" />
|
||||||
|
<span>Apontamentos Forçados Pendentes ({pendentesConfirmacao.length})</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-red-200 dark:bg-red-900/60 text-red-800 dark:text-red-300 animate-pulse">
|
||||||
|
Ação Necessária
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-red-600 dark:text-red-300">
|
||||||
|
Peças foram apontadas no processo posterior e aguardam sua confirmação de baixa:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendentesConfirmacao.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="p-2.5 rounded-xl bg-white dark:bg-slate-900 border border-red-200 dark:border-red-800/60 flex items-center justify-between gap-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className="font-mono font-black text-xs text-red-700 dark:text-red-400">
|
||||||
|
{item.marca}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-bold px-1.5 py-0.2 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300">
|
||||||
|
OF {item.of_number} • F{item.fase}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-bold px-1.5 py-0.2 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${item.processo_cor}20`,
|
||||||
|
color: item.processo_cor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.processo_nome}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[10px] text-slate-500 dark:text-slate-400 flex items-center gap-2 flex-wrap">
|
||||||
|
<span>Qtd: <strong>{item.quantidade} un</strong></span>
|
||||||
|
{item.forcado_por_user_nome && (
|
||||||
|
<span className="text-red-600 dark:text-red-400 font-semibold">
|
||||||
|
Forçado por: {item.forcado_por_user_nome}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>{formatarTempo(item.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={confirmandoId === item.id}
|
||||||
|
onClick={() => handleConfirmarBaixaForcada(item)}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-red-600 hover:bg-red-700 active:bg-red-800 text-white font-black text-xs flex items-center gap-1 shrink-0 shadow-sm active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
{confirmandoId === item.id ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCheck className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>Confirmar Baixa</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Seção 2: Histórico de Apontamentos Recentes (Hoje e Ontem) */}
|
||||||
{carregando && apontamentos.length === 0 ? (
|
{carregando && apontamentos.length === 0 ? (
|
||||||
<div className="py-12 flex flex-col items-center justify-center text-slate-400 space-y-2">
|
<div className="py-12 flex flex-col items-center justify-center text-slate-400 space-y-2">
|
||||||
<RefreshCw className="w-7 h-7 animate-spin text-amber-500" />
|
<RefreshCw className="w-7 h-7 animate-spin text-amber-500" />
|
||||||
<span className="text-xs font-medium">Buscando novos apontamentos...</span>
|
<span className="text-xs font-medium">Buscando novos apontamentos...</span>
|
||||||
</div>
|
</div>
|
||||||
) : apontamentos.length === 0 ? (
|
) : outrosApontamentos.length === 0 && pendentesConfirmacao.length === 0 ? (
|
||||||
<div className="py-12 flex flex-col items-center justify-center text-center text-slate-400 space-y-2">
|
<div className="py-12 flex flex-col items-center justify-center text-center text-slate-400 space-y-2">
|
||||||
<CheckCircle2 className="w-10 h-10 stroke-[1.2] text-slate-300 dark:text-slate-600" />
|
<CheckCircle2 className="w-10 h-10 stroke-[1.2] text-slate-300 dark:text-slate-600" />
|
||||||
<p className="text-xs font-semibold text-slate-600 dark:text-slate-400">
|
<p className="text-xs font-semibold text-slate-600 dark:text-slate-400">
|
||||||
Nenhum apontamento registrado recentemente{ofFiltrar ? ` para a OF ${ofFiltrar}` : ''}.
|
Nenhum apontamento registrado hoje ou ontem{ofFiltrar ? ` para a OF ${ofFiltrar}` : ''}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
apontamentos.map((item) => (
|
outrosApontamentos.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="p-3 rounded-2xl bg-slate-50/80 dark:bg-slate-950/60 hover:bg-slate-100/90 dark:hover:bg-slate-800/60 border border-slate-200 dark:border-slate-800 transition-all duration-150 flex items-start justify-between gap-3 shadow-sm"
|
className="p-3 rounded-2xl bg-slate-50/80 dark:bg-slate-950/60 hover:bg-slate-100/90 dark:hover:bg-slate-800/60 border border-slate-200 dark:border-slate-800 transition-all duration-150 flex items-start justify-between gap-3 shadow-sm"
|
||||||
@@ -257,6 +394,11 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
>
|
>
|
||||||
{item.processo_nome}
|
{item.processo_nome}
|
||||||
</span>
|
</span>
|
||||||
|
{item.is_forcado && (
|
||||||
|
<span className="text-[9px] font-black px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-950 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-800">
|
||||||
|
⚡ Forçado (Confirmado)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.perfil && (
|
{item.perfil && (
|
||||||
@@ -291,7 +433,7 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
|
|
||||||
{/* Rodapé */}
|
{/* Rodapé */}
|
||||||
<div className="p-3 bg-slate-50/90 dark:bg-slate-950/80 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between text-[11px] text-slate-500">
|
<div className="p-3 bg-slate-50/90 dark:bg-slate-950/80 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
<span>{apontamentos.length} apontamentos listados</span>
|
<span>{apontamentos.length} apontamentos listados (hoje e ontem)</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -308,55 +450,61 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hook reativo para alertar e contar novos apontamentos no sininho
|
// Hook reativo para alertar e contar novos apontamentos no sininho (Hoje e Ontem)
|
||||||
export const useSmartNotificationsAlert = (ofFiltrar?: string) => {
|
export const useSmartNotificationsAlert = (ofFiltrar?: string) => {
|
||||||
const [unreadCount, setUnreadCount] = useState<number>(0);
|
const [unreadCount, setUnreadCount] = useState<number>(0);
|
||||||
const [hasNewAlert, setHasNewAlert] = useState<boolean>(false);
|
const [hasNewAlert, setHasNewAlert] = useState<boolean>(false);
|
||||||
|
const [hasPendingForced, setHasPendingForced] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchRecentCount = useCallback(async () => {
|
||||||
// 1. Busca contagem de apontamentos de hoje / recentes
|
|
||||||
const fetchRecentCount = async () => {
|
|
||||||
try {
|
try {
|
||||||
const hoje = new Date();
|
const dataOntem = new Date(Date.now() - 86400000);
|
||||||
hoje.setHours(0, 0, 0, 0);
|
const dataOntemIso = dataOntem.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// 1. Contar apontamentos recentes (hoje e ontem)
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from('apontamentos_producao' as any)
|
.from('apontamentos_producao' as any)
|
||||||
.select('id', { count: 'exact', head: true })
|
.select('id, is_forcado, status_confirmacao', { count: 'exact' })
|
||||||
.gte('created_at', hoje.toISOString());
|
.gte('data_apontamento', dataOntemIso);
|
||||||
|
|
||||||
if (ofFiltrar) {
|
if (ofFiltrar) {
|
||||||
query = query.eq('of_number', ofFiltrar);
|
query = query.eq('of_number', ofFiltrar);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { count, error } = await query;
|
const { data, count, error } = await query;
|
||||||
if (!error && count !== null) {
|
if (!error && count !== null) {
|
||||||
setUnreadCount(count);
|
setUnreadCount(count);
|
||||||
if (count > 0) setHasNewAlert(true);
|
if (count > 0) setHasNewAlert(true);
|
||||||
|
|
||||||
|
const pending = (data || []).some(
|
||||||
|
(row: any) => row.is_forcado && row.status_confirmacao === 'pendente_confirmacao'
|
||||||
|
);
|
||||||
|
setHasPendingForced(pending);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.debug('Erro ao contar apontamentos:', err);
|
console.debug('Erro ao contar apontamentos:', err);
|
||||||
}
|
}
|
||||||
};
|
}, [ofFiltrar]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchRecentCount();
|
fetchRecentCount();
|
||||||
|
|
||||||
// 2. Realtime listener para alertar quando novos apontamentos forem inseridos
|
// Realtime listener para alertar quando novos apontamentos forem inseridos ou atualizados
|
||||||
const channelName = `realtime-smart-notifs-${ofFiltrar || 'all'}-${Math.random().toString(36).substring(2, 7)}`;
|
const channelName = `realtime-smart-notifs-${ofFiltrar || 'all'}-${Math.random().toString(36).substring(2, 7)}`;
|
||||||
const channel = supabase
|
const channel = supabase
|
||||||
.channel(channelName)
|
.channel(channelName)
|
||||||
.on(
|
.on(
|
||||||
'postgres_changes',
|
'postgres_changes',
|
||||||
{
|
{
|
||||||
event: 'INSERT',
|
event: '*',
|
||||||
schema: 'public',
|
schema: 'public',
|
||||||
table: 'apontamentos_producao',
|
table: 'apontamentos_producao',
|
||||||
},
|
},
|
||||||
(payload) => {
|
(payload) => {
|
||||||
const newRow = payload.new as any;
|
const newRow = payload.new as any;
|
||||||
if (!ofFiltrar || newRow?.of_number === ofFiltrar) {
|
if (!ofFiltrar || newRow?.of_number === ofFiltrar) {
|
||||||
setUnreadCount((c) => c + 1);
|
fetchRecentCount();
|
||||||
setHasNewAlert(true);
|
if (payload.eventType === 'INSERT') {
|
||||||
try {
|
try {
|
||||||
smartAudio.playSuccess();
|
smartAudio.playSuccess();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -364,18 +512,18 @@ export const useSmartNotificationsAlert = (ofFiltrar?: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
supabase.removeChannel(channel);
|
supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [ofFiltrar]);
|
}, [ofFiltrar, fetchRecentCount]);
|
||||||
|
|
||||||
const clearAlert = useCallback(() => {
|
const clearAlert = useCallback(() => {
|
||||||
setHasNewAlert(false);
|
setHasNewAlert(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { unreadCount, hasNewAlert, clearAlert };
|
return { unreadCount, hasNewAlert, hasPendingForced, clearAlert, refetch: fetchRecentCount };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Plus,
|
Plus,
|
||||||
Minus,
|
Minus,
|
||||||
RotateCcw,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Filter,
|
Filter,
|
||||||
Delete,
|
Delete,
|
||||||
Pencil
|
Pencil,
|
||||||
|
CheckCheck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
import { useProcessosFabricacao, ProcessoFabricacao } from '@/hooks/useProcessosFabricacao';
|
import { useProcessosFabricacao, ProcessoFabricacao } from '@/hooks/useProcessosFabricacao';
|
||||||
@@ -24,6 +24,7 @@ import { supabase } from '@/integrations/supabase/client';
|
|||||||
import { smartAudio } from '@/utils/smartAudio';
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { SmartForcarApontamentoModal } from './SmartForcarApontamentoModal';
|
||||||
|
|
||||||
interface SmartProducaoFlowProps {
|
interface SmartProducaoFlowProps {
|
||||||
obra: OFAtiva;
|
obra: OFAtiva;
|
||||||
@@ -68,13 +69,11 @@ export const calcularSaldoProcessoPeca = (
|
|||||||
const nomeProcAtual = (processoAtual.nome || '').toLowerCase();
|
const nomeProcAtual = (processoAtual.nome || '').toLowerCase();
|
||||||
|
|
||||||
const ehSolda = nomeProcAtual.includes('solda');
|
const ehSolda = nomeProcAtual.includes('solda');
|
||||||
const ehMontagem = nomeProcAtual.includes('montag');
|
|
||||||
|
|
||||||
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
|
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
|
||||||
let motivoBloqueio: string | null = null;
|
let motivoBloqueio: string | null = null;
|
||||||
|
|
||||||
// REGRA 1: Peças sem montagem (S/M) não passam por Solda
|
// REGRA 1: Peças sem montagem (S/M) não passam por Solda
|
||||||
// (Não existe processo "Montagem" de fábrica - a montagem é apenas na obra)
|
|
||||||
if (ehSemMontagem && ehSolda) {
|
if (ehSemMontagem && ehSolda) {
|
||||||
qtdDisponivelParaEntrar = 0;
|
qtdDisponivelParaEntrar = 0;
|
||||||
motivoBloqueio = 'Peça S/M (Pula Solda - vai direto para Pintura)';
|
motivoBloqueio = 'Peça S/M (Pula Solda - vai direto para Pintura)';
|
||||||
@@ -83,7 +82,7 @@ export const calcularSaldoProcessoPeca = (
|
|||||||
let procAnteriorValido: ProcessoFabricacao | null = null;
|
let procAnteriorValido: ProcessoFabricacao | null = null;
|
||||||
for (let i = indexAtual - 1; i >= 0; i--) {
|
for (let i = indexAtual - 1; i >= 0; i--) {
|
||||||
const proc = procsOrdenados[i];
|
const proc = procsOrdenados[i];
|
||||||
const nomeP = proc.nome.toLowerCase();
|
const nomeP = (proc.nome || '').toLowerCase();
|
||||||
// Se a peça for S/M, pula solda na busca do anterior (ex: Corte -> Pintura)
|
// Se a peça for S/M, pula solda na busca do anterior (ex: Corte -> Pintura)
|
||||||
if (ehSemMontagem && nomeP.includes('solda')) {
|
if (ehSemMontagem && nomeP.includes('solda')) {
|
||||||
continue;
|
continue;
|
||||||
@@ -112,7 +111,7 @@ export const calcularSaldoProcessoPeca = (
|
|||||||
let procSeguinteValido: ProcessoFabricacao | null = null;
|
let procSeguinteValido: ProcessoFabricacao | null = null;
|
||||||
for (let i = indexAtual + 1; i < procsOrdenados.length; i++) {
|
for (let i = indexAtual + 1; i < procsOrdenados.length; i++) {
|
||||||
const proc = procsOrdenados[i];
|
const proc = procsOrdenados[i];
|
||||||
const nomeP = proc.nome.toLowerCase();
|
const nomeP = (proc.nome || '').toLowerCase();
|
||||||
// Se a peça for S/M, pula solda na busca do subsequente
|
// Se a peça for S/M, pula solda na busca do subsequente
|
||||||
if (ehSemMontagem && nomeP.includes('solda')) {
|
if (ehSemMontagem && nomeP.includes('solda')) {
|
||||||
continue;
|
continue;
|
||||||
@@ -142,9 +141,14 @@ export const calcularSaldoProcessoPeca = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ApontamentoItem {
|
interface ApontamentoItem {
|
||||||
|
id?: string;
|
||||||
peca_id: string;
|
peca_id: string;
|
||||||
processo_id: string;
|
processo_id: string;
|
||||||
quantidade_produzida: number;
|
quantidade_produzida: number;
|
||||||
|
is_forcado?: boolean;
|
||||||
|
status_confirmacao?: string;
|
||||||
|
forcado_por_user_nome?: string;
|
||||||
|
usuario_nome?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||||
@@ -166,6 +170,9 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
const [salvando, setSalvando] = useState<boolean>(false);
|
const [salvando, setSalvando] = useState<boolean>(false);
|
||||||
const [modoEdicao, setModoEdicao] = useState<boolean>(false);
|
const [modoEdicao, setModoEdicao] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Modal de Forçar Apontamento
|
||||||
|
const [isForcarModalOpen, setIsForcarModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
// Dados carregados da OF
|
// Dados carregados da OF
|
||||||
const [pecas, setPecas] = useState<PecaData[]>([]);
|
const [pecas, setPecas] = useState<PecaData[]>([]);
|
||||||
const [apontamentosExistentes, setApontamentosExistentes] = useState<ApontamentoItem[]>([]);
|
const [apontamentosExistentes, setApontamentosExistentes] = useState<ApontamentoItem[]>([]);
|
||||||
@@ -183,7 +190,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
.eq('of_number', obra.of_number),
|
.eq('of_number', obra.of_number),
|
||||||
supabase
|
supabase
|
||||||
.from('apontamentos_producao')
|
.from('apontamentos_producao')
|
||||||
.select('peca_id, processo_id, quantidade_produzida')
|
.select('id, peca_id, processo_id, quantidade_produzida, is_forcado, status_confirmacao, forcado_por_user_nome, usuario_nome')
|
||||||
.eq('of_number', obra.of_number),
|
.eq('of_number', obra.of_number),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -191,7 +198,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
setPecas(resPecas.data);
|
setPecas(resPecas.data);
|
||||||
}
|
}
|
||||||
if (resApontamentos.data) {
|
if (resApontamentos.data) {
|
||||||
setApontamentosExistentes(resApontamentos.data);
|
setApontamentosExistentes(resApontamentos.data as any);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Erro ao carregar dados da OF:', e);
|
console.error('Erro ao carregar dados da OF:', e);
|
||||||
@@ -228,6 +235,52 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
return mapa;
|
return mapa;
|
||||||
}, [apontamentosExistentes]);
|
}, [apontamentosExistentes]);
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
);
|
||||||
|
}, [apontamentosExistentes]);
|
||||||
|
|
||||||
|
// Contagem de pendências por processo (para alertar no Step 1.1)
|
||||||
|
const contagemPendentesPorProcesso = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
apontamentosPendentes.forEach((ap) => {
|
||||||
|
map.set(ap.processo_id, (map.get(ap.processo_id) || 0) + 1);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [apontamentosPendentes]);
|
||||||
|
|
||||||
|
// Contagem de pendências por fase no processo selecionado (para alertar no Step 1.2)
|
||||||
|
const contagemPendentesPorFase = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
if (!processoSelecionado) return map;
|
||||||
|
|
||||||
|
apontamentosPendentes.forEach((ap) => {
|
||||||
|
if (ap.processo_id === processoSelecionado.id) {
|
||||||
|
const peca = pecas.find((p) => p.id === ap.peca_id);
|
||||||
|
const fase = (peca?.etapa_fase || 'Geral').trim();
|
||||||
|
map.set(fase, (map.get(fase) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [apontamentosPendentes, processoSelecionado, pecas]);
|
||||||
|
|
||||||
|
// Mapa de pendências por peça no processo selecionado (para alertar no Step 1.3)
|
||||||
|
const pecasPendentesMap = useMemo(() => {
|
||||||
|
const map = new Map<string, ApontamentoItem[]>();
|
||||||
|
if (!processoSelecionado) return map;
|
||||||
|
|
||||||
|
apontamentosPendentes.forEach((ap) => {
|
||||||
|
if (ap.processo_id === processoSelecionado.id) {
|
||||||
|
const lista = map.get(ap.peca_id) || [];
|
||||||
|
lista.push(ap);
|
||||||
|
map.set(ap.peca_id, lista);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [apontamentosPendentes, processoSelecionado]);
|
||||||
|
|
||||||
// Peças filtradas pela fase selecionada com cálculo estrito de precedência de processos e regra S/M
|
// Peças filtradas pela fase selecionada com cálculo estrito de precedência de processos e regra S/M
|
||||||
const pecasComSaldo = useMemo(() => {
|
const pecasComSaldo = useMemo(() => {
|
||||||
if (!processoSelecionado) return [];
|
if (!processoSelecionado) return [];
|
||||||
@@ -235,7 +288,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
return pecas
|
return pecas
|
||||||
.filter((p) => {
|
.filter((p) => {
|
||||||
if (!faseSelecionada || faseSelecionada === 'Geral') return true;
|
if (!faseSelecionada || faseSelecionada === 'Geral') return true;
|
||||||
return p.etapa_fase?.trim() === faseSelecionada;
|
return (p.etapa_fase || 'Geral').trim() === faseSelecionada;
|
||||||
})
|
})
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const info = calcularSaldoProcessoPeca(p, processoSelecionado, processos, mapaProducao);
|
const info = calcularSaldoProcessoPeca(p, processoSelecionado, processos, mapaProducao);
|
||||||
@@ -245,16 +298,22 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Colocar primeiro as que têm saldo pendente disponível > 0
|
// Colocar primeiro as que têm pendência de confirmação de baixa
|
||||||
|
const temPendA = pecasPendentesMap.has(a.id);
|
||||||
|
const temPendB = pecasPendentesMap.has(b.id);
|
||||||
|
if (temPendA && !temPendB) return -1;
|
||||||
|
if (!temPendA && temPendB) return 1;
|
||||||
|
|
||||||
|
// Depois as que têm saldo pendente disponível > 0
|
||||||
if (a.saldo > 0 && b.saldo === 0) return -1;
|
if (a.saldo > 0 && b.saldo === 0) return -1;
|
||||||
if (a.saldo === 0 && b.saldo > 0) return 1;
|
if (a.saldo === 0 && b.saldo > 0) return 1;
|
||||||
return a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' });
|
return a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
});
|
});
|
||||||
}, [pecas, faseSelecionada, processoSelecionado, mapaProducao, processos]);
|
}, [pecas, faseSelecionada, processoSelecionado, mapaProducao, processos, pecasPendentesMap]);
|
||||||
|
|
||||||
// Helper de ícone por processo
|
// Helper de ícone por processo
|
||||||
const getIconeProcesso = (nome: string) => {
|
const getIconeProcesso = (nome: string) => {
|
||||||
const n = nome.toLowerCase();
|
const n = (nome || '').toLowerCase();
|
||||||
if (n.includes('corte')) return <Scissors className="h-6 w-6" />;
|
if (n.includes('corte')) return <Scissors className="h-6 w-6" />;
|
||||||
if (n.includes('dobra') || n.includes('fura')) return <Ruler className="h-6 w-6" />;
|
if (n.includes('dobra') || n.includes('fura')) return <Ruler className="h-6 w-6" />;
|
||||||
if (n.includes('montag')) return <Puzzle className="h-6 w-6" />;
|
if (n.includes('montag')) return <Puzzle className="h-6 w-6" />;
|
||||||
@@ -285,6 +344,34 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
setEtapaAtual('quantidade');
|
setEtapaAtual('quantidade');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Confirmar baixa direta de apontamento forçado pendente
|
||||||
|
const handleConfirmarBaixaDireta = async (apontamentoId: string, marca: string) => {
|
||||||
|
try {
|
||||||
|
smartAudio.playClick();
|
||||||
|
const nomeOperador = user?.name || user?.username || user?.email?.split('@')[0] || 'Operador';
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('apontamentos_producao' as any)
|
||||||
|
.update({
|
||||||
|
status_confirmacao: 'confirmado',
|
||||||
|
data_confirmacao: new Date().toISOString(),
|
||||||
|
confirmado_por_user_nome: nomeOperador,
|
||||||
|
confirmado_por_user_id: user?.id,
|
||||||
|
})
|
||||||
|
.eq('id', apontamentoId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
smartAudio.playSuccess();
|
||||||
|
toast.success(`✅ Baixa confirmada com sucesso: ${marca}`);
|
||||||
|
await carregarDadosOF();
|
||||||
|
} catch (e: any) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
console.error('Erro ao confirmar baixa:', e);
|
||||||
|
toast.error('Erro ao confirmar baixa: ' + (e?.message || 'Falha de conexão'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Salvar apontamento no Supabase
|
// Salvar apontamento no Supabase
|
||||||
const handleConfirmarApontamento = async () => {
|
const handleConfirmarApontamento = async () => {
|
||||||
if (!pecaSelecionada || !processoSelecionado || !user) return;
|
if (!pecaSelecionada || !processoSelecionado || !user) return;
|
||||||
@@ -311,6 +398,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
try {
|
try {
|
||||||
setSalvando(true);
|
setSalvando(true);
|
||||||
const hoje = new Date().toISOString().split('T')[0];
|
const hoje = new Date().toISOString().split('T')[0];
|
||||||
|
const nomeUsuario = user.name || user.username || user.email?.split('@')[0] || 'Operador';
|
||||||
|
|
||||||
let delta = qtdApontar;
|
let delta = qtdApontar;
|
||||||
if (modoEdicao) {
|
if (modoEdicao) {
|
||||||
@@ -335,6 +423,9 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
quantidade_produzida: delta,
|
quantidade_produzida: delta,
|
||||||
data_apontamento: hoje,
|
data_apontamento: hoje,
|
||||||
created_by: user.id,
|
created_by: user.id,
|
||||||
|
usuario_nome: nomeUsuario,
|
||||||
|
is_forcado: false,
|
||||||
|
status_confirmacao: 'confirmado',
|
||||||
observacoes: modoEdicao ? 'Ajuste de quantidade via Modo Smart' : 'Apontado via Modo Smart',
|
observacoes: modoEdicao ? 'Ajuste de quantidade via Modo Smart' : 'Apontado via Modo Smart',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -357,15 +448,8 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
onApontamentoRealizado(qtdApontar, pesoTotalKg);
|
onApontamentoRealizado(qtdApontar, pesoTotalKg);
|
||||||
|
|
||||||
// FAST LOOP / STICKY CONTEXT:
|
// FAST LOOP / STICKY CONTEXT:
|
||||||
// Atualiza o estado local imediatamente e volta para a lista de peças daquela Fase/Processo!
|
// Atualiza os dados imediatamente e volta para a lista de peças daquela Fase/Processo!
|
||||||
setApontamentosExistentes((prev) => [
|
await carregarDadosOF();
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
peca_id: pecaSelecionada.id,
|
|
||||||
processo_id: processoSelecionado.id,
|
|
||||||
quantidade_produzida: delta,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Permanece na mesma fase para apontar a próxima peça sem voltar tudo!
|
// Permanece na mesma fase para apontar a próxima peça sem voltar tudo!
|
||||||
setEtapaAtual('pecas');
|
setEtapaAtual('pecas');
|
||||||
@@ -387,13 +471,13 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
<div className="mb-4 text-center">
|
<div className="mb-4 text-center">
|
||||||
<span className="text-xs font-black uppercase text-amber-400 tracking-wider">
|
<span className="text-xs font-black uppercase text-amber-500 dark:text-amber-400 tracking-wider">
|
||||||
ETAPA 1.1 — FABRICAÇÃO
|
ETAPA 1.1 — FABRICAÇÃO
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-xl font-black text-slate-100 uppercase mt-0.5">
|
<h2 className="text-xl font-black text-slate-900 dark:text-slate-100 uppercase mt-0.5">
|
||||||
Qual é o seu Processo?
|
Qual é o seu Processo?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
Selecione o posto de trabalho em que você está operando
|
Selecione o posto de trabalho em que você está operando
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -405,56 +489,88 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
<span>Carregando etapas...</span>
|
<span>Carregando etapas...</span>
|
||||||
</div>
|
</div>
|
||||||
) : processos.length === 0 ? (
|
) : processos.length === 0 ? (
|
||||||
// Fallback com processos industriais corretos (ordem real de produção)
|
// Fallback com processos industriais corretos
|
||||||
[
|
[
|
||||||
{ id: '1', nome: 'Corte', ordem: 1 },
|
{ id: '1', nome: 'Corte', ordem: 1 },
|
||||||
{ id: '2', nome: 'Dobra / Furação', ordem: 2 },
|
{ id: '2', nome: 'Dobra / Furação', ordem: 2 },
|
||||||
{ id: '3', nome: 'Solda', ordem: 3 },
|
{ id: '3', nome: 'Solda', ordem: 3 },
|
||||||
{ id: '4', nome: 'Pintura', ordem: 4 },
|
{ id: '4', nome: 'Pintura', ordem: 4 },
|
||||||
{ id: '5', nome: 'Galvanização', ordem: 5 },
|
{ id: '5', nome: 'Galvanização', ordem: 5 },
|
||||||
].map((p) => (
|
].map((p) => {
|
||||||
|
const pendentesCount = contagemPendentesPorProcesso.get(p.id) || 0;
|
||||||
|
const hasPend = pendentesCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleEscolherProcesso(p as ProcessoFabricacao)}
|
onClick={() => handleEscolherProcesso(p as ProcessoFabricacao)}
|
||||||
className="min-h-[72px] p-4 rounded-2xl bg-white hover:bg-amber-50/60 active:bg-amber-100/60 dark:bg-slate-800/90 dark:hover:bg-slate-750 dark:active:bg-slate-700 border-2 border-slate-200 hover:border-amber-400/80 dark:border-slate-700 dark:hover:border-amber-500/80 flex items-center justify-between text-left transition-all active:scale-[0.98] group shadow-sm"
|
className={`min-h-[72px] p-4 rounded-2xl border-2 flex items-center justify-between text-left transition-all active:scale-[0.98] group shadow-sm ${
|
||||||
|
hasPend
|
||||||
|
? 'animate-pulse-red-fast bg-red-50/80 dark:bg-red-950/40 border-red-500 shadow-md shadow-red-500/20'
|
||||||
|
: 'bg-white hover:bg-amber-50/60 active:bg-amber-100/60 dark:bg-slate-800/90 dark:hover:bg-slate-750 dark:active:bg-slate-700 border-slate-200 hover:border-amber-400/80 dark:border-slate-700 dark:hover:border-amber-500/80'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-12 w-12 rounded-xl bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-400 flex items-center justify-center shrink-0">
|
<div className={`h-12 w-12 rounded-xl flex items-center justify-center shrink-0 ${
|
||||||
|
hasPend ? 'bg-red-500 text-white animate-bounce' : 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-400'
|
||||||
|
}`}>
|
||||||
{getIconeProcesso(p.nome)}
|
{getIconeProcesso(p.nome)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-base font-black text-slate-900 dark:text-slate-100 uppercase tracking-wide">
|
<div className="text-base font-black text-slate-900 dark:text-slate-100 uppercase tracking-wide flex items-center gap-2">
|
||||||
{p.nome}
|
<span>{p.nome}</span>
|
||||||
|
{hasPend && (
|
||||||
|
<span className="text-[10px] font-black px-1.5 py-0.5 rounded-full bg-red-600 text-white">
|
||||||
|
⚠️ {pendentesCount} {pendentesCount === 1 ? 'pendência' : 'pendências'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium">Toque para selecionar</div>
|
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium">Toque para selecionar</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-6 w-6 text-slate-400 group-hover:text-amber-600 dark:text-slate-500 dark:group-hover:text-amber-400" />
|
<ChevronRight className="h-6 w-6 text-slate-400 group-hover:text-amber-600 dark:text-slate-500 dark:group-hover:text-amber-400" />
|
||||||
</button>
|
</button>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
processos.map((proc) => (
|
processos.map((proc) => {
|
||||||
|
const pendentesCount = contagemPendentesPorProcesso.get(proc.id) || 0;
|
||||||
|
const hasPend = pendentesCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={proc.id}
|
key={proc.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleEscolherProcesso(proc)}
|
onClick={() => handleEscolherProcesso(proc)}
|
||||||
className="min-h-[72px] p-4 rounded-2xl bg-white hover:bg-amber-50/60 active:bg-amber-100/60 dark:bg-slate-800/90 dark:hover:bg-slate-750 dark:active:bg-slate-700 border-2 border-slate-200 hover:border-amber-400/80 dark:border-slate-700 dark:hover:border-amber-500/80 flex items-center justify-between text-left transition-all active:scale-[0.98] group shadow-sm"
|
className={`min-h-[72px] p-4 rounded-2xl border-2 flex items-center justify-between text-left transition-all active:scale-[0.98] group shadow-sm ${
|
||||||
|
hasPend
|
||||||
|
? 'animate-pulse-red-fast bg-red-50/80 dark:bg-red-950/40 border-red-500 shadow-md shadow-red-500/20'
|
||||||
|
: 'bg-white hover:bg-amber-50/60 active:bg-amber-100/60 dark:bg-slate-800/90 dark:hover:bg-slate-750 dark:active:bg-slate-700 border-slate-200 hover:border-amber-400/80 dark:border-slate-700 dark:hover:border-amber-500/80'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-12 w-12 rounded-xl bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-400 flex items-center justify-center shrink-0">
|
<div className={`h-12 w-12 rounded-xl flex items-center justify-center shrink-0 ${
|
||||||
|
hasPend ? 'bg-red-500 text-white animate-bounce' : 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-400'
|
||||||
|
}`}>
|
||||||
{getIconeProcesso(proc.nome)}
|
{getIconeProcesso(proc.nome)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-base font-black text-slate-900 dark:text-slate-100 uppercase tracking-wide">
|
<div className="text-base font-black text-slate-900 dark:text-slate-100 uppercase tracking-wide flex items-center gap-2">
|
||||||
{proc.nome}
|
<span>{proc.nome}</span>
|
||||||
|
{hasPend && (
|
||||||
|
<span className="text-[10px] font-black px-1.5 py-0.5 rounded-full bg-red-600 text-white">
|
||||||
|
⚠️ {pendentesCount} {pendentesCount === 1 ? 'baixa pendente' : 'baixas pendentes'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium">Etapa #{proc.ordem}</div>
|
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium">Etapa #{proc.ordem}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-6 w-6 text-slate-400 group-hover:text-amber-600 dark:text-slate-500 dark:group-hover:text-amber-400" />
|
<ChevronRight className="h-6 w-6 text-slate-400 group-hover:text-amber-600 dark:text-slate-500 dark:group-hover:text-amber-400" />
|
||||||
</button>
|
</button>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -479,7 +595,6 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
if (etapaAtual === 'fase') {
|
if (etapaAtual === 'fase') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
{/* Breadcrumb de Processo */}
|
|
||||||
{/* Breadcrumb de Processo */}
|
{/* Breadcrumb de Processo */}
|
||||||
<div className="mb-4 flex items-center justify-between p-3 rounded-2xl bg-amber-100 dark:bg-amber-500/15 border border-amber-300 dark:border-amber-500/30 shadow-sm">
|
<div className="mb-4 flex items-center justify-between p-3 rounded-2xl bg-amber-100 dark:bg-amber-500/15 border border-amber-300 dark:border-amber-500/30 shadow-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -512,19 +627,35 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
<div className="flex-1 overflow-y-auto space-y-2.5 pb-4">
|
<div className="flex-1 overflow-y-auto space-y-2.5 pb-4">
|
||||||
{fasesDisponiveis.map((fase) => {
|
{fasesDisponiveis.map((fase) => {
|
||||||
const countFase = pecas.filter((p) => (p.etapa_fase || 'Geral').trim() === fase).length;
|
const countFase = pecas.filter((p) => (p.etapa_fase || 'Geral').trim() === fase).length;
|
||||||
|
const pendentesFaseCount = contagemPendentesPorFase.get(fase) || 0;
|
||||||
|
const hasPendFase = pendentesFaseCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={fase}
|
key={fase}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleEscolherFase(fase)}
|
onClick={() => handleEscolherFase(fase)}
|
||||||
className="w-full min-h-[68px] p-4 rounded-2xl bg-white hover:bg-amber-50/60 active:bg-amber-100/60 dark:bg-slate-800/90 dark:hover:bg-slate-750 dark:active:bg-slate-700 border-2 border-slate-200 hover:border-amber-400/80 dark:border-slate-700 dark:hover:border-amber-500/80 flex items-center justify-between text-left transition-all active:scale-[0.98] group shadow-sm"
|
className={`w-full min-h-[68px] p-4 rounded-2xl border-2 flex items-center justify-between text-left transition-all active:scale-[0.98] group shadow-sm ${
|
||||||
|
hasPendFase
|
||||||
|
? 'animate-pulse-red-fast bg-red-50/80 dark:bg-red-950/40 border-red-500 shadow-md shadow-red-500/20'
|
||||||
|
: 'bg-white hover:bg-amber-50/60 active:bg-amber-100/60 dark:bg-slate-800/90 dark:hover:bg-slate-750 dark:active:bg-slate-700 border-slate-200 hover:border-amber-400/80 dark:border-slate-700 dark:hover:border-amber-500/80'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-11 w-11 rounded-xl bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200 flex items-center justify-center shrink-0">
|
<div className={`h-11 w-11 rounded-xl flex items-center justify-center shrink-0 ${
|
||||||
|
hasPendFase ? 'bg-red-500 text-white animate-bounce' : 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200'
|
||||||
|
}`}>
|
||||||
<Layers className="h-5 w-5" />
|
<Layers className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-base font-black text-slate-900 dark:text-slate-100 uppercase">{fase}</div>
|
<div className="text-base font-black text-slate-900 dark:text-slate-100 uppercase flex items-center gap-2">
|
||||||
|
<span>{fase}</span>
|
||||||
|
{hasPendFase && (
|
||||||
|
<span className="text-[10px] font-black px-1.5 py-0.5 rounded-full bg-red-600 text-white">
|
||||||
|
⚠️ {pendentesFaseCount} {pendentesFaseCount === 1 ? 'baixa pendente' : 'baixas pendentes'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">{countFase} peças cadastradas</div>
|
<div className="text-xs text-slate-500 dark:text-slate-400">{countFase} peças cadastradas</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,10 +684,12 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
// RENDER: ETAPA 1.3 — LISTA DE PEÇAS (FAST LOOP / STICKY CONTEXT)
|
// RENDER: ETAPA 1.3 — LISTA DE PEÇAS (FAST LOOP / STICKY CONTEXT)
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
if (etapaAtual === 'pecas') {
|
if (etapaAtual === 'pecas') {
|
||||||
|
const isPrimeiroProcesso = processoSelecionado && processos.length > 0 && processoSelecionado.id === [...processos].sort((a,b) => a.ordem - b.ordem)[0]?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
{/* Sticky Context: Breadcrumbs Interativos no Topo */}
|
{/* Sticky Context: Breadcrumbs Interativos no Topo + Botão de Forçar Apontamento */}
|
||||||
<div className="mb-3 p-3 rounded-2xl bg-white dark:bg-slate-800/95 border border-slate-200 dark:border-slate-700 flex items-center justify-between gap-2 shadow-sm">
|
<div className="mb-3 p-3 rounded-2xl bg-white dark:bg-slate-800/95 border border-slate-200 dark:border-slate-700 flex items-center justify-between gap-2 shadow-sm flex-wrap">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -583,10 +716,27 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isPrimeiroProcesso && processoSelecionado && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setIsForcarModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 font-black text-xs uppercase flex items-center gap-1 shadow-sm active:scale-95 transition-all"
|
||||||
|
title="Forçar apontamento de peças esquecidas no processo anterior"
|
||||||
|
>
|
||||||
|
<Zap className="w-3.5 h-3.5 fill-current animate-pulse" />
|
||||||
|
<span>Forçar Apontamento</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="text-[11px] font-bold text-slate-500 dark:text-slate-400 shrink-0">
|
<span className="text-[11px] font-bold text-slate-500 dark:text-slate-400 shrink-0">
|
||||||
{pecasComSaldo.length} itens
|
{pecasComSaldo.length} itens
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Lista de Peças */}
|
{/* Lista de Peças */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-3 pb-4">
|
<div className="flex-1 overflow-y-auto space-y-3 pb-4">
|
||||||
@@ -605,19 +755,24 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
pecasComSaldo.map((item) => {
|
pecasComSaldo.map((item) => {
|
||||||
|
const pendenciasItem = pecasPendentesMap.get(item.id) || [];
|
||||||
|
const temPendencia = pendenciasItem.length > 0;
|
||||||
const concluida = item.saldo <= 0;
|
const concluida = item.saldo <= 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`p-4 rounded-2xl border-2 transition-all shadow-sm ${
|
className={`p-4 rounded-2xl border-2 transition-all shadow-sm ${
|
||||||
concluida
|
temPendencia
|
||||||
|
? 'animate-pulse-red-fast bg-red-50/70 dark:bg-red-950/40 border-red-500 shadow-md shadow-red-500/20'
|
||||||
|
: concluida
|
||||||
? 'bg-slate-50 dark:bg-slate-900/60 border-slate-200 dark:border-slate-800 opacity-75'
|
? 'bg-slate-50 dark:bg-slate-900/60 border-slate-200 dark:border-slate-800 opacity-75'
|
||||||
: 'bg-white dark:bg-slate-800/95 border-slate-200 dark:border-slate-700/90 hover:border-amber-400 dark:hover:border-amber-500/80'
|
: 'bg-white dark:bg-slate-800/95 border-slate-200 dark:border-slate-700/90 hover:border-amber-400 dark:hover:border-amber-500/80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xl font-black text-amber-700 dark:text-amber-400 tracking-wider">
|
<span className="text-xl font-black text-amber-700 dark:text-amber-400 tracking-wider">
|
||||||
{item.marca}
|
{item.marca}
|
||||||
</span>
|
</span>
|
||||||
@@ -700,6 +855,27 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
/>
|
/>
|
||||||
</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">
|
||||||
|
<div className="text-xs font-bold text-red-800 dark:text-red-300 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 animate-bounce shrink-0" />
|
||||||
|
<span>
|
||||||
|
Baixa forçada ({pendenciasItem.reduce((acc, p) => acc + (p.quantidade_produzida || 0), 0)} un)
|
||||||
|
{pendenciasItem[0]?.forcado_por_user_nome && ` • Por: ${pendenciasItem[0].forcado_por_user_nome}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleConfirmarBaixaDireta(pendenciasItem[0].id!, item.marca)}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-red-600 hover:bg-red-700 active:bg-red-800 text-white text-xs font-black uppercase flex items-center gap-1 shadow-sm active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
<CheckCheck className="w-3.5 h-3.5" />
|
||||||
|
<span>Confirmar Baixa</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Botões de Ação no Card */}
|
{/* Botões de Ação no Card */}
|
||||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -755,6 +931,24 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
>
|
>
|
||||||
⬅️ Voltar às Fases
|
⬅️ Voltar às Fases
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Modal de Forçar Apontamento */}
|
||||||
|
{processoSelecionado && (
|
||||||
|
<SmartForcarApontamentoModal
|
||||||
|
isOpen={isForcarModalOpen}
|
||||||
|
onClose={() => setIsForcarModalOpen(false)}
|
||||||
|
obra={obra}
|
||||||
|
faseSelecionada={faseSelecionada}
|
||||||
|
processoAtual={processoSelecionado}
|
||||||
|
todosProcessos={processos}
|
||||||
|
pecas={pecas}
|
||||||
|
mapaProducao={mapaProducao}
|
||||||
|
onSucesso={(qtd, peso) => {
|
||||||
|
carregarDadosOF();
|
||||||
|
onApontamentoRealizado(qtd, peso);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -785,7 +979,6 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
const handleDigitoTeclado = (digito: number) => {
|
const handleDigitoTeclado = (digito: number) => {
|
||||||
smartAudio.playClick();
|
smartAudio.playClick();
|
||||||
setQtdApontar((prev) => {
|
setQtdApontar((prev) => {
|
||||||
// Se estiver em 0 ou for primeira digitação
|
|
||||||
const strAtual = prev <= 0 ? '' : prev.toString();
|
const strAtual = prev <= 0 ? '' : prev.toString();
|
||||||
const novaStr = strAtual + digito.toString();
|
const novaStr = strAtual + digito.toString();
|
||||||
const novoValor = parseInt(novaStr, 10) || 0;
|
const novoValor = parseInt(novaStr, 10) || 0;
|
||||||
@@ -845,7 +1038,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
{pecaSelecionada.tem_componentes === false && (
|
{pecaSelecionada.tem_componentes === false && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] px-2 py-0.5 rounded-md bg-purple-100 text-purple-800 border border-purple-300 dark:bg-purple-950/80 dark:text-purple-300 dark:border-purple-800/50 font-black"
|
className="text-[10px] px-2 py-0.5 rounded-md bg-purple-100 text-purple-800 border border-purple-300 dark:bg-purple-950/80 dark:text-purple-300 dark:border-purple-800/50 font-black"
|
||||||
title="Peça Sem Montagem (Pula Solda e Montagem)"
|
title="Peça Sem Montagem (Pula Solda)"
|
||||||
>
|
>
|
||||||
S/M
|
S/M
|
||||||
</span>
|
</span>
|
||||||
@@ -883,7 +1076,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
|||||||
<Minus className="h-6 w-6" />
|
<Minus className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Input Numérico Editável Direto (Toque para digitar ou use o teclado da tela) */}
|
{/* Input Numérico Editável Direto */}
|
||||||
<div className="flex-1 max-w-[180px] h-16 rounded-2xl bg-slate-50 dark:bg-slate-900 border-2 border-amber-500 flex items-center justify-center shadow-inner relative">
|
<div className="flex-1 max-w-[180px] h-16 rounded-2xl bg-slate-50 dark:bg-slate-900 border-2 border-amber-500 flex items-center justify-center shadow-inner relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
Reference in New Issue
Block a user