feat: forcar apontamento modo smart, notificacoes hoje/ontem e alertas visuais 0.5s

This commit is contained in:
2026-09-24 20:32:50 +00:00
parent bdf46437af
commit 1c2f80aca9
7 changed files with 971 additions and 138 deletions
@@ -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>
+9
View File
@@ -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;
+19
View File
@@ -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>
);
};
+15 -7
View File
@@ -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 };
};
+271 -78
View File
@@ -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"