Files
tracksteel_app/src/pages/smart/components/SmartNotificationsModal.tsx
T

382 lines
14 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { smartAudio } from '@/utils/smartAudio';
import {
Bell,
X,
CheckCircle2,
User,
Clock,
Layers,
RefreshCw,
Flame,
Wrench,
Paintbrush,
Truck,
Scissors,
Box
} from 'lucide-react';
export interface ApontamentoNotificacao {
id: string;
of_number: string;
quantidade: number;
data_apontamento: string;
created_at: string;
marca: string;
fase: string;
perfil?: string;
processo_nome: string;
processo_cor: string;
usuario_nome: string;
}
interface SmartNotificationsModalProps {
isOpen: boolean;
onClose: () => void;
ofFiltrar?: string;
}
export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = ({
isOpen,
onClose,
ofFiltrar,
}) => {
const [apontamentos, setApontamentos] = useState<ApontamentoNotificacao[]>([]);
const [carregando, setCarregando] = useState<boolean>(false);
const formatarTempo = (dataIso?: string) => {
if (!dataIso) return 'Recente';
try {
const data = new Date(dataIso);
const agora = new Date();
const diffMs = agora.getTime() - data.getTime();
const diffMin = Math.floor(diffMs / (1000 * 60));
const diffHoras = Math.floor(diffMin / 60);
if (diffMin < 1) return 'Agora mesmo';
if (diffMin < 60) return `há ${diffMin} min`;
if (diffHoras < 24) return `há ${diffHoras}h`;
return data.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
} catch {
return 'Recente';
}
};
const getProcessIcon = (processoNome: string) => {
const norm = (processoNome || '').toLowerCase();
if (norm.includes('corte')) return <Scissors className="w-3.5 h-3.5" />;
if (norm.includes('solda')) return <Flame className="w-3.5 h-3.5" />;
if (norm.includes('pintu') || norm.includes('galv')) return <Paintbrush className="w-3.5 h-3.5" />;
if (norm.includes('exped')) return <Truck className="w-3.5 h-3.5" />;
if (norm.includes('montag')) return <Wrench className="w-3.5 h-3.5" />;
return <Box className="w-3.5 h-3.5" />;
};
const carregarNotificacoes = useCallback(async () => {
setCarregando(true);
try {
// 1. Buscar os últimos 30 apontamentos
let query = supabase
.from('apontamentos_producao' as any)
.select(`
id, of_number, quantidade_produzida, data_apontamento, created_at, created_by,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase, perfil_principal, descricao),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`)
.order('created_at', { ascending: false })
.limit(30);
if (ofFiltrar) {
query = query.eq('of_number', ofFiltrar);
}
const { data: apontamentosData, error } = await query;
if (error) throw error;
// 2. Buscar nomes dos usuários dos apontamentos
const userIds = Array.from(new Set((apontamentosData || []).map((ap: any) => ap.created_by).filter(Boolean)));
const profilesMap = new Map<string, string>();
if (userIds.length > 0) {
try {
const { data: profs } = await supabase
.from('profiles' as any)
.select('id, full_name, email')
.in('id', userIds);
if (profs) {
profs.forEach((pr: any) => {
const nome = pr.full_name || pr.email?.split('@')[0] || 'Operador';
profilesMap.set(pr.id, nome);
});
}
} catch (e) {
console.debug('Erro ao buscar perfis:', e);
}
}
// 3. 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';
return {
id: String(ap.id),
of_number: ap.of_number || 'OF',
quantidade: Number(ap.quantidade_produzida || 1),
data_apontamento: ap.data_apontamento,
created_at: ap.created_at || ap.data_apontamento,
marca: peca.marca || 'Sem Marca',
fase: peca.etapa_fase || '1',
perfil: peca.perfil_principal || peca.descricao || '',
processo_nome: processo.nome || 'Produção',
processo_cor: processo.cor || '#10b981',
usuario_nome: userName,
};
});
setApontamentos(lista);
} catch (err) {
console.error('Erro ao carregar notificações de produção:', err);
} finally {
setCarregando(false);
}
}, [ofFiltrar]);
useEffect(() => {
if (isOpen) {
carregarNotificacoes();
}
}, [isOpen, carregarNotificacoes]);
if (!isOpen) return null;
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"
onClick={onClose}
>
<div
className="w-full max-w-lg max-h-[85vh] flex flex-col bg-white dark:bg-slate-900 rounded-3xl border border-slate-200 dark:border-slate-800 shadow-2xl overflow-hidden select-none animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Cabeçalho do Modal */}
<div className="flex items-center justify-between px-5 py-4 bg-slate-50/90 dark:bg-slate-950/80 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-2xl bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-500 dark:text-amber-400">
<Bell className="w-5 h-5 animate-pulse" />
</div>
<div>
<h2 className="text-sm sm:text-base font-black uppercase tracking-wider text-slate-800 dark:text-slate-100 flex items-center gap-2">
Apontamentos Recentes
{ofFiltrar && (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-cyan-100 dark:bg-cyan-900/40 text-cyan-700 dark:text-cyan-300">
OF {ofFiltrar}
</span>
)}
</h2>
<p className="text-[11px] text-slate-500 dark:text-slate-400">
Histórico em tempo real das peças produzidas
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
smartAudio.playClick();
carregarNotificacoes();
}}
disabled={carregando}
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"
title="Atualizar Notificações"
>
<RefreshCw className={`w-4 h-4 ${carregando ? 'animate-spin text-amber-500' : ''}`} />
</button>
<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"
title="Fechar"
>
<X className="w-4 h-4" />
</button>
</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">
{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 ? (
<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}` : ''}.
</p>
</div>
) : (
apontamentos.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"
>
{/* Lado Esquerdo: Marca, Detalhes e Usuário */}
<div className="flex items-start gap-2.5 min-w-0 flex-1">
<div
className="p-2 rounded-xl shrink-0 flex items-center justify-center text-white shadow-sm"
style={{ backgroundColor: item.processo_cor }}
title={item.processo_nome}
>
{getProcessIcon(item.processo_nome)}
</div>
<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-xs sm:text-sm text-slate-900 dark:text-slate-100 tracking-tight">
{item.marca}
</span>
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-200 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.5 rounded"
style={{
backgroundColor: `${item.processo_cor}20`,
color: item.processo_cor,
}}
>
{item.processo_nome}
</span>
</div>
{item.perfil && (
<p className="text-[11px] font-mono text-slate-600 dark:text-slate-400 truncate">
{item.perfil}
</p>
)}
<div className="flex items-center gap-3 text-[10px] text-slate-500 dark:text-slate-400 pt-0.5">
<span className="flex items-center gap-1 font-semibold text-slate-700 dark:text-slate-300">
<User className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
{item.usuario_nome}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatarTempo(item.created_at)}
</span>
</div>
</div>
</div>
{/* Lado Direito: Quantidade Apontada */}
<div className="text-right shrink-0 flex flex-col items-end justify-center">
<span className="text-xs font-mono font-black px-2 py-1 rounded-xl bg-emerald-100 dark:bg-emerald-950/60 text-emerald-800 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-800 shadow-sm">
+{item.quantidade} un
</span>
</div>
</div>
))
)}
</div>
{/* 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>
<button
type="button"
onClick={() => {
smartAudio.playClick();
onClose();
}}
className="px-3.5 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-white font-bold text-xs active:scale-95 transition-all"
>
Fechar
</button>
</div>
</div>
</div>
);
};
// Hook reativo para alertar e contar novos apontamentos no sininho
export const useSmartNotificationsAlert = (ofFiltrar?: string) => {
const [unreadCount, setUnreadCount] = useState<number>(0);
const [hasNewAlert, setHasNewAlert] = useState<boolean>(false);
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
const channelName = `realtime-smart-notifs-${ofFiltrar || 'all'}-${Math.random().toString(36).substring(2, 7)}`;
const channel = supabase
.channel(channelName)
.on(
'postgres_changes',
{
event: 'INSERT',
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
}
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [ofFiltrar]);
const clearAlert = useCallback(() => {
setHasNewAlert(false);
}, []);
return { unreadCount, hasNewAlert, clearAlert };
};