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="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">
|
||||
{getMarcaItem(apontamento)}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{apontamento.of_number || 'N/A'}
|
||||
</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 className="text-sm text-muted-foreground">
|
||||
{apontamento.processo?.nome || 'N/A'} • {formatDate(apontamento.data_apontamento)}
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
|
||||
@@ -47,6 +47,15 @@ export interface ApontamentoProducao {
|
||||
observacoes?: string;
|
||||
created_at: 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?: {
|
||||
marca: string;
|
||||
descricao: string;
|
||||
|
||||
@@ -211,4 +211,23 @@
|
||||
50% {
|
||||
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 [soundOn, setSoundOn] = useState<boolean>(() => smartAudio.isSoundEnabled());
|
||||
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
|
||||
const isDark =
|
||||
@@ -140,20 +140,27 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
|
||||
setIsNotificationsOpen(true);
|
||||
}}
|
||||
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-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' : ''}`} />
|
||||
{hasNewAlert && (
|
||||
<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' : ''}`} />
|
||||
{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="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>
|
||||
)}
|
||||
{unreadCount > 0 && !hasNewAlert && (
|
||||
) : null}
|
||||
{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">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
@@ -202,6 +209,7 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
|
||||
<SmartNotificationsModal
|
||||
isOpen={isNotificationsOpen}
|
||||
onClose={() => setIsNotificationsOpen(false)}
|
||||
onApontamentoConfirmado={refetch}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { smartAudio } from '@/utils/smartAudio';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Bell,
|
||||
X,
|
||||
@@ -14,7 +16,9 @@ import {
|
||||
Paintbrush,
|
||||
Truck,
|
||||
Scissors,
|
||||
Box
|
||||
Box,
|
||||
AlertTriangle,
|
||||
CheckCheck
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface ApontamentoNotificacao {
|
||||
@@ -29,21 +33,28 @@ export interface ApontamentoNotificacao {
|
||||
processo_nome: string;
|
||||
processo_cor: string;
|
||||
usuario_nome: string;
|
||||
is_forcado?: boolean;
|
||||
status_confirmacao?: 'confirmado' | 'pendente_confirmacao' | string;
|
||||
forcado_por_user_nome?: string;
|
||||
}
|
||||
|
||||
interface SmartNotificationsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
ofFiltrar?: string;
|
||||
onApontamentoConfirmado?: () => void;
|
||||
}
|
||||
|
||||
export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ofFiltrar,
|
||||
onApontamentoConfirmado,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [apontamentos, setApontamentos] = useState<ApontamentoNotificacao[]>([]);
|
||||
const [carregando, setCarregando] = useState<boolean>(false);
|
||||
const [confirmandoId, setConfirmandoId] = useState<string | null>(null);
|
||||
|
||||
const formatarTempo = (dataIso?: string) => {
|
||||
if (!dataIso) return 'Recente';
|
||||
@@ -76,16 +87,21 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
||||
const carregarNotificacoes = useCallback(async () => {
|
||||
setCarregando(true);
|
||||
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
|
||||
.from('apontamentos_producao' as any)
|
||||
.select(`
|
||||
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),
|
||||
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
|
||||
`)
|
||||
.gte('data_apontamento', dataOntemIso)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(30);
|
||||
.limit(100);
|
||||
|
||||
if (ofFiltrar) {
|
||||
query = query.eq('of_number', ofFiltrar);
|
||||
@@ -94,7 +110,7 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
||||
const { data: apontamentosData, error } = await query;
|
||||
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 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 peca = ap.peca || {};
|
||||
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 {
|
||||
id: String(ap.id),
|
||||
@@ -134,6 +150,9 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
||||
processo_nome: processo.nome || 'Produção',
|
||||
processo_cor: processo.cor || '#10b981',
|
||||
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]);
|
||||
|
||||
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(() => {
|
||||
if (isOpen) {
|
||||
carregarNotificacoes();
|
||||
@@ -153,6 +211,13 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
||||
|
||||
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 (
|
||||
<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"
|
||||
@@ -178,7 +243,7 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
||||
)}
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,22 +275,94 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Notificações de Apontamentos */}
|
||||
<div className="flex-1 overflow-y-auto p-3 sm:p-4 space-y-2.5 custom-scrollbar">
|
||||
{/* Conteúdo com Apontamentos */}
|
||||
<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 ? (
|
||||
<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" />
|
||||
<span className="text-xs font-medium">Buscando novos apontamentos...</span>
|
||||
</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">
|
||||
<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">
|
||||
Nenhum apontamento registrado recentemente{ofFiltrar ? ` para a OF ${ofFiltrar}` : ''}.
|
||||
Nenhum apontamento registrado hoje ou ontem{ofFiltrar ? ` para a OF ${ofFiltrar}` : ''}.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
apontamentos.map((item) => (
|
||||
outrosApontamentos.map((item) => (
|
||||
<div
|
||||
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"
|
||||
@@ -257,6 +394,11 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
||||
>
|
||||
{item.processo_nome}
|
||||
</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>
|
||||
|
||||
{item.perfil && (
|
||||
@@ -291,7 +433,7 @@ export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = (
|
||||
|
||||
{/* 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">
|
||||
<span>{apontamentos.length} apontamentos listados</span>
|
||||
<span>{apontamentos.length} apontamentos listados (hoje e ontem)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -308,59 +450,66 @@ 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) => {
|
||||
const [unreadCount, setUnreadCount] = useState<number>(0);
|
||||
const [hasNewAlert, setHasNewAlert] = useState<boolean>(false);
|
||||
const [hasPendingForced, setHasPendingForced] = useState<boolean>(false);
|
||||
|
||||
const fetchRecentCount = useCallback(async () => {
|
||||
try {
|
||||
const dataOntem = new Date(Date.now() - 86400000);
|
||||
const dataOntemIso = dataOntem.toISOString().split('T')[0];
|
||||
|
||||
// 1. Contar apontamentos recentes (hoje e ontem)
|
||||
let query = supabase
|
||||
.from('apontamentos_producao' as any)
|
||||
.select('id, is_forcado, status_confirmacao', { count: 'exact' })
|
||||
.gte('data_apontamento', dataOntemIso);
|
||||
|
||||
if (ofFiltrar) {
|
||||
query = query.eq('of_number', ofFiltrar);
|
||||
}
|
||||
|
||||
const { data, count, error } = await query;
|
||||
if (!error && count !== null) {
|
||||
setUnreadCount(count);
|
||||
if (count > 0) setHasNewAlert(true);
|
||||
|
||||
const pending = (data || []).some(
|
||||
(row: any) => row.is_forcado && row.status_confirmacao === 'pendente_confirmacao'
|
||||
);
|
||||
setHasPendingForced(pending);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Erro ao contar apontamentos:', err);
|
||||
}
|
||||
}, [ofFiltrar]);
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Busca contagem de apontamentos de hoje / recentes
|
||||
const fetchRecentCount = async () => {
|
||||
try {
|
||||
const hoje = new Date();
|
||||
hoje.setHours(0, 0, 0, 0);
|
||||
|
||||
let query = supabase
|
||||
.from('apontamentos_producao' as any)
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.gte('created_at', hoje.toISOString());
|
||||
|
||||
if (ofFiltrar) {
|
||||
query = query.eq('of_number', ofFiltrar);
|
||||
}
|
||||
|
||||
const { count, error } = await query;
|
||||
if (!error && count !== null) {
|
||||
setUnreadCount(count);
|
||||
if (count > 0) setHasNewAlert(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Erro ao contar apontamentos:', err);
|
||||
}
|
||||
};
|
||||
|
||||
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 channel = supabase
|
||||
.channel(channelName)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'apontamentos_producao',
|
||||
},
|
||||
(payload) => {
|
||||
const newRow = payload.new as any;
|
||||
if (!ofFiltrar || newRow?.of_number === ofFiltrar) {
|
||||
setUnreadCount((c) => c + 1);
|
||||
setHasNewAlert(true);
|
||||
try {
|
||||
smartAudio.playSuccess();
|
||||
} catch {
|
||||
// ignore
|
||||
fetchRecentCount();
|
||||
if (payload.eventType === 'INSERT') {
|
||||
try {
|
||||
smartAudio.playSuccess();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,12 +519,11 @@ export const useSmartNotificationsAlert = (ofFiltrar?: string) => {
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [ofFiltrar]);
|
||||
}, [ofFiltrar, fetchRecentCount]);
|
||||
|
||||
const clearAlert = useCallback(() => {
|
||||
setHasNewAlert(false);
|
||||
}, []);
|
||||
|
||||
return { unreadCount, hasNewAlert, clearAlert };
|
||||
return { unreadCount, hasNewAlert, hasPendingForced, clearAlert, refetch: fetchRecentCount };
|
||||
};
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
Check,
|
||||
Layers,
|
||||
Plus,
|
||||
Minus,
|
||||
RotateCcw,
|
||||
Minus,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Delete,
|
||||
Pencil
|
||||
Pencil,
|
||||
CheckCheck
|
||||
} from 'lucide-react';
|
||||
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||
import { useProcessosFabricacao, ProcessoFabricacao } from '@/hooks/useProcessosFabricacao';
|
||||
@@ -24,6 +24,7 @@ import { supabase } from '@/integrations/supabase/client';
|
||||
import { smartAudio } from '@/utils/smartAudio';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { SmartForcarApontamentoModal } from './SmartForcarApontamentoModal';
|
||||
|
||||
interface SmartProducaoFlowProps {
|
||||
obra: OFAtiva;
|
||||
@@ -68,13 +69,11 @@ export const calcularSaldoProcessoPeca = (
|
||||
const nomeProcAtual = (processoAtual.nome || '').toLowerCase();
|
||||
|
||||
const ehSolda = nomeProcAtual.includes('solda');
|
||||
const ehMontagem = nomeProcAtual.includes('montag');
|
||||
|
||||
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
|
||||
let motivoBloqueio: string | null = null;
|
||||
|
||||
// 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) {
|
||||
qtdDisponivelParaEntrar = 0;
|
||||
motivoBloqueio = 'Peça S/M (Pula Solda - vai direto para Pintura)';
|
||||
@@ -83,7 +82,7 @@ export const calcularSaldoProcessoPeca = (
|
||||
let procAnteriorValido: ProcessoFabricacao | null = null;
|
||||
for (let i = indexAtual - 1; i >= 0; 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)
|
||||
if (ehSemMontagem && nomeP.includes('solda')) {
|
||||
continue;
|
||||
@@ -112,7 +111,7 @@ export const calcularSaldoProcessoPeca = (
|
||||
let procSeguinteValido: ProcessoFabricacao | null = null;
|
||||
for (let i = indexAtual + 1; i < procsOrdenados.length; 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
|
||||
if (ehSemMontagem && nomeP.includes('solda')) {
|
||||
continue;
|
||||
@@ -142,9 +141,14 @@ export const calcularSaldoProcessoPeca = (
|
||||
};
|
||||
|
||||
interface ApontamentoItem {
|
||||
id?: string;
|
||||
peca_id: string;
|
||||
processo_id: string;
|
||||
quantidade_produzida: number;
|
||||
is_forcado?: boolean;
|
||||
status_confirmacao?: string;
|
||||
forcado_por_user_nome?: string;
|
||||
usuario_nome?: string;
|
||||
}
|
||||
|
||||
export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
@@ -166,6 +170,9 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
const [salvando, setSalvando] = useState<boolean>(false);
|
||||
const [modoEdicao, setModoEdicao] = useState<boolean>(false);
|
||||
|
||||
// Modal de Forçar Apontamento
|
||||
const [isForcarModalOpen, setIsForcarModalOpen] = useState<boolean>(false);
|
||||
|
||||
// Dados carregados da OF
|
||||
const [pecas, setPecas] = useState<PecaData[]>([]);
|
||||
const [apontamentosExistentes, setApontamentosExistentes] = useState<ApontamentoItem[]>([]);
|
||||
@@ -183,7 +190,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
.eq('of_number', obra.of_number),
|
||||
supabase
|
||||
.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),
|
||||
]);
|
||||
|
||||
@@ -191,7 +198,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
setPecas(resPecas.data);
|
||||
}
|
||||
if (resApontamentos.data) {
|
||||
setApontamentosExistentes(resApontamentos.data);
|
||||
setApontamentosExistentes(resApontamentos.data as any);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erro ao carregar dados da OF:', e);
|
||||
@@ -228,6 +235,52 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
return mapa;
|
||||
}, [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
|
||||
const pecasComSaldo = useMemo(() => {
|
||||
if (!processoSelecionado) return [];
|
||||
@@ -235,7 +288,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
return pecas
|
||||
.filter((p) => {
|
||||
if (!faseSelecionada || faseSelecionada === 'Geral') return true;
|
||||
return p.etapa_fase?.trim() === faseSelecionada;
|
||||
return (p.etapa_fase || 'Geral').trim() === faseSelecionada;
|
||||
})
|
||||
.map((p) => {
|
||||
const info = calcularSaldoProcessoPeca(p, processoSelecionado, processos, mapaProducao);
|
||||
@@ -245,16 +298,22 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
};
|
||||
})
|
||||
.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;
|
||||
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
|
||||
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('dobra') || n.includes('fura')) return <Ruler 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');
|
||||
};
|
||||
|
||||
// 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
|
||||
const handleConfirmarApontamento = async () => {
|
||||
if (!pecaSelecionada || !processoSelecionado || !user) return;
|
||||
@@ -311,6 +398,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
try {
|
||||
setSalvando(true);
|
||||
const hoje = new Date().toISOString().split('T')[0];
|
||||
const nomeUsuario = user.name || user.username || user.email?.split('@')[0] || 'Operador';
|
||||
|
||||
let delta = qtdApontar;
|
||||
if (modoEdicao) {
|
||||
@@ -335,6 +423,9 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
quantidade_produzida: delta,
|
||||
data_apontamento: hoje,
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -357,15 +448,8 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
onApontamentoRealizado(qtdApontar, pesoTotalKg);
|
||||
|
||||
// FAST LOOP / STICKY CONTEXT:
|
||||
// Atualiza o estado local imediatamente e volta para a lista de peças daquela Fase/Processo!
|
||||
setApontamentosExistentes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
peca_id: pecaSelecionada.id,
|
||||
processo_id: processoSelecionado.id,
|
||||
quantidade_produzida: delta,
|
||||
},
|
||||
]);
|
||||
// Atualiza os dados imediatamente e volta para a lista de peças daquela Fase/Processo!
|
||||
await carregarDadosOF();
|
||||
|
||||
// Permanece na mesma fase para apontar a próxima peça sem voltar tudo!
|
||||
setEtapaAtual('pecas');
|
||||
@@ -387,13 +471,13 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
return (
|
||||
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||
<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
|
||||
</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?
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
@@ -405,56 +489,88 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
<span>Carregando etapas...</span>
|
||||
</div>
|
||||
) : 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: '2', nome: 'Dobra / Furação', ordem: 2 },
|
||||
{ id: '3', nome: 'Solda', ordem: 3 },
|
||||
{ id: '4', nome: 'Pintura', ordem: 4 },
|
||||
{ id: '5', nome: 'Galvanização', ordem: 5 },
|
||||
].map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{getIconeProcesso(p.nome)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-black text-slate-900 dark:text-slate-100 uppercase tracking-wide">
|
||||
{p.nome}
|
||||
].map((p) => {
|
||||
const pendentesCount = contagemPendentesPorProcesso.get(p.id) || 0;
|
||||
const hasPend = pendentesCount > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => handleEscolherProcesso(p as ProcessoFabricacao)}
|
||||
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={`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)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-black text-slate-900 dark:text-slate-100 uppercase tracking-wide flex items-center gap-2">
|
||||
<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 className="text-xs text-slate-500 dark:text-slate-400 font-medium">Toque para selecionar</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium">Toque para selecionar</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" />
|
||||
</button>
|
||||
))
|
||||
<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>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
processos.map((proc) => (
|
||||
<button
|
||||
key={proc.id}
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{getIconeProcesso(proc.nome)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-black text-slate-900 dark:text-slate-100 uppercase tracking-wide">
|
||||
{proc.nome}
|
||||
processos.map((proc) => {
|
||||
const pendentesCount = contagemPendentesPorProcesso.get(proc.id) || 0;
|
||||
const hasPend = pendentesCount > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={proc.id}
|
||||
type="button"
|
||||
onClick={() => handleEscolherProcesso(proc)}
|
||||
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={`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)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-black text-slate-900 dark:text-slate-100 uppercase tracking-wide flex items-center gap-2">
|
||||
<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 className="text-xs text-slate-500 dark:text-slate-400 font-medium">Etapa #{proc.ordem}</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium">Etapa #{proc.ordem}</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" />
|
||||
</button>
|
||||
))
|
||||
<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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -479,7 +595,6 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
if (etapaAtual === 'fase') {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||
{/* 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="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">
|
||||
{fasesDisponiveis.map((fase) => {
|
||||
const countFase = pecas.filter((p) => (p.etapa_fase || 'Geral').trim() === fase).length;
|
||||
const pendentesFaseCount = contagemPendentesPorFase.get(fase) || 0;
|
||||
const hasPendFase = pendentesFaseCount > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={fase}
|
||||
type="button"
|
||||
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="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" />
|
||||
</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>
|
||||
</div>
|
||||
@@ -553,10 +684,12 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
// RENDER: ETAPA 1.3 — LISTA DE PEÇAS (FAST LOOP / STICKY CONTEXT)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
if (etapaAtual === 'pecas') {
|
||||
const isPrimeiroProcesso = processoSelecionado && processos.length > 0 && processoSelecionado.id === [...processos].sort((a,b) => a.ordem - b.ordem)[0]?.id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||
{/* Sticky Context: Breadcrumbs Interativos no Topo */}
|
||||
<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">
|
||||
{/* 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 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -583,9 +716,26 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-[11px] font-bold text-slate-500 dark:text-slate-400 shrink-0">
|
||||
{pecasComSaldo.length} itens
|
||||
</span>
|
||||
<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">
|
||||
{pecasComSaldo.length} itens
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Peças */}
|
||||
@@ -605,19 +755,24 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
pecasComSaldo.map((item) => {
|
||||
const pendenciasItem = pecasPendentesMap.get(item.id) || [];
|
||||
const temPendencia = pendenciasItem.length > 0;
|
||||
const concluida = item.saldo <= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
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-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>
|
||||
<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">
|
||||
{item.marca}
|
||||
</span>
|
||||
@@ -700,6 +855,27 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
/>
|
||||
</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 */}
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
@@ -755,6 +931,24 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
>
|
||||
⬅️ Voltar às Fases
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -785,7 +979,6 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
const handleDigitoTeclado = (digito: number) => {
|
||||
smartAudio.playClick();
|
||||
setQtdApontar((prev) => {
|
||||
// Se estiver em 0 ou for primeira digitação
|
||||
const strAtual = prev <= 0 ? '' : prev.toString();
|
||||
const novaStr = strAtual + digito.toString();
|
||||
const novoValor = parseInt(novaStr, 10) || 0;
|
||||
@@ -845,7 +1038,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
{pecaSelecionada.tem_componentes === false && (
|
||||
<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"
|
||||
title="Peça Sem Montagem (Pula Solda e Montagem)"
|
||||
title="Peça Sem Montagem (Pula Solda)"
|
||||
>
|
||||
S/M
|
||||
</span>
|
||||
@@ -883,7 +1076,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||
<Minus className="h-6 w-6" />
|
||||
</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">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
Reference in New Issue
Block a user