// Client Portal Shell — Shell/navegação do lado cliente (Portal do Cliente MVP) // Renderizado por App.jsx quando o usuario logado é um client_user (não é da equipe). // Fase 3: conteúdo real de Dashboard, Minhas Ações (+ detalhe), Arquivos, Relatórios, // Aprovações, e wire-up do registerBudgetView. function ClientPortalShell({ session, clientUser, clientRow }) { const [page, setPage] = React.useState(() => localStorage.getItem('portal_page') || 'dashboard'); const [sidebarOpen, setSidebarOpen] = React.useState(false); const [isMobile, setIsMobile] = React.useState(window.innerWidth < 768); const [userMenuOpen, setUserMenuOpen] = React.useState(false); const [passwordModal, setPasswordModal] = React.useState(false); const [passwordForm, setPasswordForm] = React.useState({ next: '', confirm: '' }); const [passwordSaving, setPasswordSaving] = React.useState(false); const [passwordMsg, setPasswordMsg] = React.useState(null); // Quando o cliente clica numa ação/orçamento pra ver detalhes const [selectedEventId, setSelectedEventId] = React.useState(null); const [selectedBudgetId, setSelectedBudgetId] = React.useState(null); const [toast, setToast] = React.useState(null); const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3000); }; React.useEffect(() => { const onResize = () => setIsMobile(window.innerWidth < 768); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); React.useEffect(() => { if (!userMenuOpen) return; const onClickOutside = (e) => { if (e.target.closest('[data-portal-user-menu]')) return; setUserMenuOpen(false); }; document.addEventListener('mousedown', onClickOutside); return () => document.removeEventListener('mousedown', onClickOutside); }, [userMenuOpen]); const navigate = (p) => { setPage(p); setSelectedEventId(null); setSelectedBudgetId(null); localStorage.setItem('portal_page', p); if (isMobile) setSidebarOpen(false); }; const handleLogout = async () => { await sb.auth.signOut(); }; const handleChangePassword = async () => { setPasswordMsg(null); if (!passwordForm.next || passwordForm.next.length < 6) { setPasswordMsg({ type: 'error', text: 'A senha deve ter ao menos 6 caracteres.' }); return; } if (passwordForm.next !== passwordForm.confirm) { setPasswordMsg({ type: 'error', text: 'As senhas não conferem.' }); return; } setPasswordSaving(true); const { error } = await sb.auth.updateUser({ password: passwordForm.next }); setPasswordSaving(false); if (error) { setPasswordMsg({ type: 'error', text: error.message || 'Erro ao trocar senha.' }); return; } setPasswordMsg({ type: 'success', text: 'Senha atualizada com sucesso!' }); setPasswordForm({ next: '', confirm: '' }); setTimeout(() => { setPasswordModal(false); setPasswordMsg(null); }, 1500); }; const NAV = [ { id: 'dashboard', label: 'Dashboard', icon: 'dashboard' }, { id: 'acoes', label: 'Minhas Ações', icon: 'calendar' }, { id: 'orcamentos', label: 'Orçamentos', icon: 'budget' }, { id: 'aprovacoes', label: 'Aprovações', icon: 'check' }, { id: 'arquivos', label: 'Arquivos', icon: 'download' }, { id: 'relatorios', label: 'Relatórios', icon: 'reports' }, ]; const clientName = clientRow?.name || 'Cliente'; const displayName = (clientUser.name || '').charAt(0).toUpperCase() + (clientUser.name || '').slice(1); const sidebarWidth = 240; const OG = DS.colors.primary; const openEventDetail = (eventId) => { setSelectedEventId(eventId); setPage('acoes'); }; const openBudgetDetail = (budgetId) => { setSelectedBudgetId(budgetId); setPage('orcamentos'); }; return (
{/* ── Sidebar ── */} {isMobile && sidebarOpen && (
setSidebarOpen(false)} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99 }} /> )} {/* ── Main ── */}
{/* Topbar */}
{isMobile && ( )}
{NAV.find(n => n.id === page)?.label || 'Portal do Cliente'}
setUserMenuOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderRadius: DS.radius.sm, border: `1px solid ${DS.colors.border}`, cursor: 'pointer', background: userMenuOpen ? '#F8FAFC' : '#fff' }}> {!isMobile && {displayName || clientUser.email}}
{userMenuOpen && (
{displayName}
{clientUser.email}
{[ { icon: 'edit', label: 'Trocar senha', onClick: () => { setUserMenuOpen(false); setPasswordForm({ next: '', confirm: '' }); setPasswordMsg(null); setPasswordModal(true); } }, { icon: 'logout', label: 'Sair', onClick: () => { setUserMenuOpen(false); handleLogout(); }, danger: true }, ].map(item => (
e.currentTarget.style.background = item.danger ? '#FEF2F2' : '#F8FAFC'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> {item.label}
))}
)}
{/* Page content */}
{page === 'dashboard' && } {page === 'acoes' && !selectedEventId && } {page === 'acoes' && selectedEventId && setSelectedEventId(null)} clientUser={clientUser} showToast={showToast} />} {page === 'orcamentos' && !selectedBudgetId && } {page === 'orcamentos' && selectedBudgetId && setSelectedBudgetId(null)} />} {page === 'aprovacoes' && } {page === 'arquivos' && } {page === 'relatorios' && }
{/* Modal Trocar Senha */} { setPasswordModal(false); setPasswordMsg(null); }} title="Trocar senha" width={380}>
setPasswordForm(f => ({ ...f, next: v }))} placeholder="Mínimo 6 caracteres" /> setPasswordForm(f => ({ ...f, confirm: v }))} /> {passwordMsg && (
{passwordMsg.text}
)}
setPasswordModal(false)}>Cancelar {passwordSaving ? 'Salvando...' : 'Salvar'}
{toast && setToast(null)} />}
); } // ═══════════════════════════════════════════════════════════════════ // DASHBOARD — visão geral com stats e listas // ═══════════════════════════════════════════════════════════════════ function PortalDashboard({ clientRow, onOpenEvent, onOpenBudget }) { const [events, setEvents] = React.useState([]); const [budgets, setBudgets] = React.useState([]); const [approvals, setApprovals] = React.useState([]); const [reports, setReports] = React.useState([]); const [loading, setLoading] = React.useState(true); React.useEffect(() => { if (!clientRow?.name || !clientRow?.id) { setLoading(false); return; } Promise.all([ sb.from('events').select('id, name, event_date, status, location').eq('client_name', clientRow.name).order('event_date', { ascending: false }).limit(12), sb.from('budgets').select('id, code, event_name, value, status, issued_date').eq('client_name', clientRow.name).order('created_at', { ascending: false }).limit(10), sb.from('approvals').select('id, title, status').eq('client_id', clientRow.id).eq('status', 'pendente'), sb.from('event_reports').select('id, event_id, type').eq('type', 'RF').order('updated_at', { ascending: false }).limit(5), ]).then(([evRes, budRes, apprRes, rfRes]) => { setEvents(evRes.data || []); setBudgets(budRes.data || []); setApprovals(apprRes.data || []); setReports(rfRes.data || []); setLoading(false); }); }, [clientRow?.id, clientRow?.name]); if (loading) return
; const today = new Date(); today.setHours(0,0,0,0); const proximas = events.filter(e => e.event_date && new Date(e.event_date + 'T00:00:00') >= today); const emAndamento = events.filter(e => ['Confirmado','Em andamento'].includes(e.status)); const orcPendentes = budgets.filter(b => ['Enviado','Visualizado','Em análise','Solicitou ajustes'].includes(b.status)); const stats = [ { label: 'Próximas ações', value: proximas.length, icon: 'calendar', color: DS.colors.primary }, { label: 'Em andamento', value: emAndamento.length, icon: 'zap', color: DS.colors.success }, { label: 'Orçamentos abertos', value: orcPendentes.length, icon: 'budget', color: DS.colors.warning }, { label: 'Aprovações pendentes', value: approvals.length, icon: 'check', color: '#8B5CF6' }, ]; return (

Olá, {clientRow?.name || 'Cliente'} 👋

Aqui você acompanha tudo que a Promoup está fazendo pra sua marca em tempo real.

{stats.map(s => (
{s.label}
{s.value}
))}
Próximas ações
{proximas.length === 0 ? (
Nenhuma ação futura programada.
) : (
{proximas.slice(0, 5).map((ev, i) => (
onOpenEvent(ev.id)} style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr 1fr 100px', gap: 12, padding: '13px 22px', borderBottom: i < Math.min(proximas.length, 5) - 1 ? `1px solid ${DS.colors.border}` : 'none', alignItems: 'center', cursor: 'pointer', transition: 'background 0.15s' }} onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{ev.name}
{ev.event_date ? new Date(ev.event_date + 'T00:00:00').toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' }) : '—'}
{ev.location || '—'}
{ev.status || '—'}
))}
)}
{budgets.length > 0 && (
Últimos orçamentos
{budgets.slice(0, 5).map((b, i) => { const variant = (window.BUDGET_STATUS_VARIANT || {})[b.status] || 'default'; return (
onOpenBudget(b.id)} style={{ display: 'grid', gridTemplateColumns: '80px 1.5fr 1fr 130px 120px', gap: 12, padding: '13px 22px', borderBottom: i < Math.min(budgets.length, 5) - 1 ? `1px solid ${DS.colors.border}` : 'none', alignItems: 'center', cursor: 'pointer', transition: 'background 0.15s' }} onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{b.code}
{b.event_name || '—'}
{b.issued_date || '—'}
R$ {Number(b.value || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
{b.status}
); })}
)}
); } // ═══════════════════════════════════════════════════════════════════ // MINHAS AÇÕES — lista todos os eventos do cliente // ═══════════════════════════════════════════════════════════════════ function PortalMinhasAcoes({ clientRow, onOpen }) { const [events, setEvents] = React.useState([]); const [loading, setLoading] = React.useState(true); const [tab, setTab] = React.useState('todos'); React.useEffect(() => { if (!clientRow?.name) { setLoading(false); return; } sb.from('events').select('*').eq('client_name', clientRow.name).order('event_date', { ascending: false }).then(({ data }) => { setEvents(data || []); setLoading(false); }); }, [clientRow?.name]); if (loading) return
; const today = new Date(); today.setHours(0,0,0,0); const futuros = events.filter(e => e.event_date && new Date(e.event_date + 'T00:00:00') >= today); const passados = events.filter(e => e.event_date && new Date(e.event_date + 'T00:00:00') < today); const filtered = tab === 'futuros' ? futuros : tab === 'passados' ? passados : events; return (

Minhas Ações

Todas as ações da sua marca — clique em qualquer uma pra ver detalhes.

{[['todos', `Todas (${events.length})`], ['futuros', `Futuras (${futuros.length})`], ['passados', `Concluídas (${passados.length})`]].map(([id, label]) => ( ))}
{filtered.length === 0 ? (
Nenhuma ação neste filtro
) : (
Ação
Data
Local
Status
{filtered.map((ev, i) => (
onOpen(ev.id)} style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1.4fr 100px', gap: 12, padding: '14px 20px', borderBottom: i < filtered.length - 1 ? `1px solid ${DS.colors.border}` : 'none', alignItems: 'center', cursor: 'pointer', transition: 'background 0.15s' }} onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{ev.name}
{ev.type &&
{ev.type}
}
{ev.event_date ? new Date(ev.event_date + 'T00:00:00').toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'}
{ev.location || '—'}
{ev.status || '—'}
))}
)}
); } // ═══════════════════════════════════════════════════════════════════ // EVENT DETAIL — briefing, cronograma, timeline de operação, arquivos // ═══════════════════════════════════════════════════════════════════ function PortalEventDetail({ eventId, clientRow, onBack, clientUser, showToast }) { const [event, setEvent] = React.useState(null); const [tasks, setTasks] = React.useState([]); const [updates, setUpdates] = React.useState([]); const [files, setFiles] = React.useState([]); const [loading, setLoading] = React.useState(true); React.useEffect(() => { Promise.all([ sb.from('events').select('*').eq('id', eventId).maybeSingle(), sb.from('operation_updates').select('*').eq('event_id', eventId).order('created_at', { ascending: false }), sb.from('project_files').select('*').eq('event_id', eventId).eq('visible_to_client', true).order('created_at', { ascending: false }), ]).then(async ([evRes, updRes, filesRes]) => { setEvent(evRes.data || null); setUpdates(updRes.data || []); setFiles(filesRes.data || []); // tarefas via event_name (schema legado) if (evRes.data?.name) { const { data: tks } = await sb.from('tasks').select('*').eq('event_name', evRes.data.name).order('created_at'); setTasks(tks || []); } setLoading(false); }); }, [eventId]); const handleDownload = async (file) => { const { data, error } = await sb.storage.from('project-files').createSignedUrl(file.storage_path, 3600); if (error) { showToast('Erro ao gerar link: ' + error.message, 'error'); return; } window.open(data.signedUrl, '_blank'); }; if (loading) return
; if (!event) return Ação não encontrada.; const eventDate = event.event_date ? new Date(event.event_date + 'T00:00:00') : null; const daysUntil = eventDate ? Math.round((eventDate - new Date().setHours(0,0,0,0)) / 86400000) : null; const timelineIcon = { chegada: 'zap', inicio: 'zap', foto: 'photo', ocorrencia: 'alert', encerramento: 'check', nota: 'edit' }; const timelineColor = { chegada: '#3B82F6', inicio: '#8B5CF6', foto: '#10B981', ocorrencia: '#F59E0B', encerramento: '#059669', nota: '#64748B' }; const timelineLabel = { chegada: 'Chegada da equipe', inicio: 'Ação iniciada', foto: 'Foto da ativação', ocorrencia: 'Ocorrência', encerramento: 'Ação encerrada', nota: 'Atualização' }; return (
{event.status}
{event.name}
{eventDate ? eventDate.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' }) : 'Sem data'} {daysUntil !== null && daysUntil > 0 && · em {daysUntil} dia{daysUntil !== 1 ? 's' : ''}} {daysUntil === 0 && HOJE}
{event.location && (
{event.location}
)}
{event.description && (
Briefing
{event.description}
)} {/* Atualizações da Operação */}
Atualizações da Operação
{updates.length === 0 ? (
Sem atualizações da equipe ainda.
) : (
{updates.map((u, i) => (
{i < updates.length - 1 &&
}
{timelineLabel[u.type] || u.type}
{new Date(u.created_at).toLocaleString('pt-BR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })} {u.created_by_name && ` · por ${u.created_by_name}`}
{u.text &&
{u.text}
} {u.image_url && Foto da operação}
))}
)} {/* Cronograma via tasks */} {tasks.length > 0 && (
Cronograma
{tasks.slice(0, 12).map((t, i) => (
{t.column_key === 'done' && }
{t.title}
))}
)} {/* Arquivos do projeto */} {files.length > 0 && (
Arquivos deste projeto ({files.length})
{files.map((f, i) => (
{f.filename}
{fileCategoryLabel(f.category)}
{formatFileSize(f.size_bytes)}
{new Date(f.created_at).toLocaleDateString('pt-BR')}
handleDownload(f)}>Baixar
))}
)}
); } // ═══════════════════════════════════════════════════════════════════ // ORÇAMENTOS (listagem) e DETAIL (com wire-up de view tracking) // ═══════════════════════════════════════════════════════════════════ function PortalOrcamentos({ clientRow, onOpen }) { const [budgets, setBudgets] = React.useState([]); const [loading, setLoading] = React.useState(true); React.useEffect(() => { if (!clientRow?.name) { setLoading(false); return; } sb.from('budgets').select('*').eq('client_name', clientRow.name).order('created_at', { ascending: false }).then(({ data }) => { setBudgets(data || []); setLoading(false); }); }, [clientRow?.name]); if (loading) return
; return (

Orçamentos

Todos os orçamentos que a Promoup enviou pra você.

{budgets.length === 0 ? (
Nenhum orçamento por aqui ainda.
) : (
Código
Evento
Emissão
Valor
Status
{budgets.map((b, i) => { const variant = (window.BUDGET_STATUS_VARIANT || {})[b.status] || 'default'; return (
onOpen(b.id)} style={{ display: 'grid', gridTemplateColumns: '90px 2fr 130px 130px 130px', gap: 12, padding: '14px 20px', borderBottom: i < budgets.length - 1 ? `1px solid ${DS.colors.border}` : 'none', alignItems: 'center', cursor: 'pointer', transition: 'background 0.15s' }} onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{b.code}
{b.event_name || '—'}
{b.issued_date || '—'}
R$ {Number(b.value || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
{b.status}
); })}
)}
); } function PortalBudgetDetail({ budgetId, clientUser, clientRow, onBack }) { const [budget, setBudget] = React.useState(null); const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(true); const trackedRef = React.useRef(false); React.useEffect(() => { Promise.all([ sb.from('budgets').select('*').eq('id', budgetId).maybeSingle(), sb.from('budget_items').select('*').eq('budget_id', budgetId), ]).then(([bRes, iRes]) => { setBudget(bRes.data || null); setItems(iRes.data || []); setLoading(false); // Wire-up: registra visualização (uma vez por sessão de tela) if (bRes.data && !trackedRef.current && window.registerBudgetView) { trackedRef.current = true; window.registerBudgetView({ budget_id: budgetId, viewer_name: clientUser?.name || null, viewer_email: clientUser?.email || null, }); } }); }, [budgetId]); if (loading) return
; if (!budget) return Orçamento não encontrado.; const subtotal = items.reduce((s, i) => s + (parseFloat(i.total) || 0), 0); const discountPct = parseFloat(budget.discount) || 0; const feePct = parseFloat(budget.fee) || 0; const discountAmt = subtotal * discountPct / 100; const feeAmt = subtotal * feePct / 100; const total = subtotal - discountAmt + feeAmt; const variant = (window.BUDGET_STATUS_VARIANT || {})[budget.status] || 'default'; const fmt = v => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return (
Orçamento
{budget.code}
{budget.event_name || '—'}
Emitido em {budget.issued_date || '—'}
{budget.status}
{['Descrição', 'Qtd', 'Período', 'Valor Unit.', 'Total'].map((h, i) => ( ))} {items.length === 0 ? ( ) : items.map((it, i) => ( ))}
{h}
Nenhum item
{it.description}
{it.service_description &&
{it.service_description}
}
{it.qty} {it.periodo && Number(it.periodo) !== 1 ? it.periodo : '—'} {fmt(it.unit_price)} {fmt(it.total)}
Subtotal{fmt(subtotal)}
{discountPct > 0 && (
Desconto ({discountPct}%)-{fmt(discountAmt)}
)} {feePct > 0 && (
Taxa ({feePct}%){fmt(feeAmt)}
)}
Total{fmt(total)}
{budget.complementares && (
Informações Complementares
{budget.complementares}
)}
); } // ═══════════════════════════════════════════════════════════════════ // APROVAÇÕES — cliente aprova ou solicita ajuste // ═══════════════════════════════════════════════════════════════════ function PortalAprovacoes({ clientRow, clientUser, showToast }) { const [approvals, setApprovals] = React.useState([]); const [loading, setLoading] = React.useState(true); const [ajusteModal, setAjusteModal] = React.useState(null); const [ajusteReason, setAjusteReason] = React.useState(''); const [saving, setSaving] = React.useState(false); const [tab, setTab] = React.useState('pendentes'); const fetchApprovals = () => { if (!clientRow?.id) { setLoading(false); return; } sb.from('approvals').select('*').eq('client_id', clientRow.id).order('created_at', { ascending: false }).then(({ data }) => { setApprovals(data || []); setLoading(false); }); }; React.useEffect(() => { fetchApprovals(); }, [clientRow?.id]); const handleAprovar = async (appr) => { if (!window.confirm(`Aprovar "${appr.title}"?`)) return; setSaving(true); await sb.from('approvals').update({ status: 'aprovado', decided_by_client_user_id: clientUser.id, decided_by_name: clientUser.name, decided_at: new Date().toISOString(), }).eq('id', appr.id); // Notifica equipe if (window.createNotification) { await window.createNotification({ type: 'budget_approved', title: `${clientUser.name || clientRow?.name} aprovou: ${appr.title}`, message: `Aprovação registrada no Portal do Cliente`, link: appr.resource_type === 'orcamento' ? 'budgets' : 'events', icon: 'check', }); } setSaving(false); showToast('Aprovação registrada!'); fetchApprovals(); }; const handleAjuste = async () => { if (!ajusteReason.trim()) { showToast('Descreva o ajuste solicitado.', 'error'); return; } setSaving(true); await sb.from('approvals').update({ status: 'ajuste_solicitado', ajuste_reason: ajusteReason.trim(), decided_by_client_user_id: clientUser.id, decided_by_name: clientUser.name, decided_at: new Date().toISOString(), }).eq('id', ajusteModal.id); if (window.createNotification) { await window.createNotification({ type: 'budget_ajuste', title: `${clientUser.name || clientRow?.name} solicitou ajuste em: ${ajusteModal.title}`, message: ajusteReason.trim().slice(0, 140), link: ajusteModal.resource_type === 'orcamento' ? 'budgets' : 'events', icon: 'alert', }); } setSaving(false); setAjusteModal(null); setAjusteReason(''); showToast('Solicitação de ajuste enviada.'); fetchApprovals(); }; if (loading) return
; const pendentes = approvals.filter(a => a.status === 'pendente'); const decididas = approvals.filter(a => a.status !== 'pendente'); const list = tab === 'pendentes' ? pendentes : decididas; return (

Aprovações

Aprove ou solicite ajustes — sua decisão é registrada com data e hora.

{[['pendentes', `Pendentes (${pendentes.length})`], ['decididas', `Histórico (${decididas.length})`]].map(([id, label]) => ( ))}
{list.length === 0 ? (
{tab === 'pendentes' ? 'Tudo em dia! Nenhuma aprovação pendente.' : 'Nenhuma decisão no histórico ainda.'}
) : (
{list.map(a => { const badgeVariant = a.status === 'pendente' ? 'warning' : a.status === 'aprovado' ? 'success' : 'primary'; const badgeLabel = { pendente: 'Aguardando você', aprovado: 'Aprovado', ajuste_solicitado: 'Ajuste solicitado' }[a.status]; return (
{a.resource_type}
{a.title}
{a.description &&
{a.description}
}
{badgeLabel}
{a.status === 'ajuste_solicitado' && a.ajuste_reason && (
Ajuste pedido: {a.ajuste_reason}
)} {a.decided_at && (
Decidido por {a.decided_by_name || 'você'} em {new Date(a.decided_at).toLocaleString('pt-BR')}
)} {a.status === 'pendente' && (
handleAprovar(a)} disabled={saving} icon="check">Aprovar { setAjusteModal(a); setAjusteReason(''); }} disabled={saving} icon="edit">Solicitar ajuste
)}
); })}
)} { setAjusteModal(null); setAjusteReason(''); }} title="Solicitar ajuste" width={480}>
Item
{ajusteModal?.title}