// ============ PRISMA · PANEL CLIENTE — screens ============ const { useState: useSS, useMemo: useSM, useRef: useSR, useEffect: useSE } = React; // ---- LOGIN ---- const ClScreenLogin = ({ onLogin, error }) => { const [email, setEmail] = useSS(''); const [pass, setPass] = useSS(''); const [loading, setLoading] = useSS(false); const [focus, setFocus] = useSS(null); const fieldStyle = (id) => ({ width: '100%', background: 'var(--bg-0)', color: 'var(--txt)', border: '1px solid ' + (focus === id ? 'var(--line-strong)' : 'var(--line)'), borderRadius: 'var(--r-md)', padding: '11px 13px 11px 38px', fontSize: 14, outline: 'none', transition: 'border-color .15s', }); const handleSubmit = async (e) => { e.preventDefault(); setLoading(true); await onLogin(email, pass); setLoading(false); }; return (
Prisma

Portal de clientes

Acceso exclusivo para empresas con proyectos en Prisma Studios.

{error && (
{error}
)}
Prisma Studios · Portal exclusivo para clientes
); }; // ---- DASHBOARD ---- const ClScreenDashboard = ({ building, counts }) => { const [events, setEvents] = useSS([]); const [loading, setLoading] = useSS(true); const [lastUpdate, setLastUpdate] = useSS(Date.now()); const [refreshKey, setRefreshKey] = useSS(0); const [minAgo, setMinAgo] = useSS(0); const ac = window.acHex(building.accent); // Auto-refresh cada 5 minutos useSE(() => { const id = setInterval(() => setRefreshKey(k => k + 1), 5 * 60 * 1000); return () => clearInterval(id); }, []); // Actualizar label "hace X min" cada minuto useSE(() => { const id = setInterval(() => { setMinAgo(Math.floor((Date.now() - lastUpdate) / 60000)); }, 60 * 1000); return () => clearInterval(id); }, [lastUpdate]); useSE(() => { let active = true; setLoading(true); const since = new Date(Date.now() - 24 * 3600 * 1000).toISOString(); window._sb .from('eventos') .select('tipo, valor, created_at') .eq('edificio_id', building.id) .gte('created_at', since) .order('created_at', { ascending: false }) .limit(200) .then(({ data }) => { if (active) { setEvents(data || []); setLoading(false); setLastUpdate(Date.now()); setMinAgo(0); } }); return () => { active = false; }; }, [building.id, refreshKey]); // Derivar stats const stats = useSM(() => { const visitas = events.filter(e => e.tipo === 'vista_seccion').length; const clicks = events.filter(e => e.tipo === 'click_unidad').length; const whatsapp = events.filter(e => e.tipo === 'click_whatsapp').length; const unitCounts = events .filter(e => e.tipo === 'click_unidad' && e.valor) .reduce((a, e) => { a[e.valor] = (a[e.valor] || 0) + 1; return a; }, {}); const topUnit = Object.entries(unitCounts).sort((a, b) => b[1] - a[1])[0]; const tourCounts = events .filter(e => e.tipo === 'click_360' && e.valor) .reduce((a, e) => { a[e.valor] = (a[e.valor] || 0) + 1; return a; }, {}); const top360 = Object.entries(tourCounts).sort((a, b) => b[1] - a[1])[0]; const pisoCounts = events .filter(e => e.tipo === 'click_piso' && e.valor) .reduce((a, e) => { a[e.valor] = (a[e.valor] || 0) + 1; return a; }, {}); const topFloor = Object.entries(pisoCounts).sort((a, b) => b[1] - a[1])[0]; return { visitas, clicks, whatsapp, topUnit: topUnit ? topUnit[0] : '—', top360: top360 ? top360[0] : '—', topFloor: topFloor ? topFloor[0] : '—', }; }, [events]); const eventIcon = { view: window.CiEye, whatsapp: window.CiChat, tour: window.CiTour, pdf: window.CiPdf }; const eventAcc = { view: building.accent, whatsapp: 'teal', tour: 'azul', pdf: 'indigo' }; const recentEvents = useSM(() => events.slice(0, 5).map(e => { const kind = window.CL_TIPO_KIND[e.tipo] || 'view'; const hora = new Date(e.created_at).toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' }); return { time: hora, type: window.CL_TIPO_LABEL[e.tipo] || e.tipo, detail: e.valor || '', kind }; }), [events]); return (
Resumen
{building.company || building.name}
{building.name} · {minAgo === 0 ? 'actualizado ahora' : `hace ${minAgo} min`}
{/* disponibilidad */}
{/* destacados showroom (últimas 24h) */}
Destacados del showroom
Últimas 24 horas
{/* actividad */}
Actividad del showroom
Últimas 24 horas
Últimos eventos
{loading ? (
Cargando…
) : recentEvents.length === 0 ? (
Sin actividad en las últimas 24 horas.
) : recentEvents.map((ev, i) => { const Icon = eventIcon[ev.kind] || window.CiEye; const evAc = window.acHex(eventAcc[ev.kind] || 'azul'); return (
{ev.time} {ev.type} {ev.detail}
); })}
); }; const ClHighlightCell = ({ icon: Icon, accent, label, value, border }) => { const c = window.acHex(accent); return (
{label}
{value}
); }; const ClMiniStat = ({ icon: Icon, value, label, accent }) => { const c = window.acHex(accent); return (
{value}
{label}
); }; // ---- DISPONIBILIDAD ---- const ClAvailChip = ({ floor, pending, onClick }) => { const s = window.CL_STATUS[floor.status]; const isVendido = floor.status === 'vendido'; return ( ); }; const PER_PAGE = 10; const ClPageNav = ({ page, total, onChange }) => { if (total <= 1) return null; const pages = total <= 7 ? Array.from({ length: total }, (_, i) => i) : page < 4 ? [...Array.from({ length: 5 }, (_, i) => i), '…', total - 1] : page > total - 5 ? [0, '…', ...Array.from({ length: 5 }, (_, i) => total - 5 + i)] : [0, '…', page - 1, page, page + 1, '…', total - 1]; return (
{pages.map((p, i) => p === '…' ? ( ) : ( ))}
); }; const ClRefreshBtn = ({ onClick }) => ( ); const ClScreenDisponibilidad = ({ building, work, saved, onCycle, onRefresh }) => { const [page, setPage] = useSS(0); const counts = useSM(() => window.clCountStatuses(work), [work]); const savedFloor = (ri, fi) => saved[ri]?.floors[fi]?.status; const dirty = useSM(() => JSON.stringify(work) !== JSON.stringify(saved), [work, saved]); const totalPages = Math.ceil(work.length / PER_PAGE); const pageRows = work.slice(page * PER_PAGE, (page + 1) * PER_PAGE); const pageOffset = page * PER_PAGE; useSE(() => { setPage(0); }, [building.id]); if (work.length === 0) { return (
Gestión
Estado de unidades — {building.name}
No hay unidades registradas para este edificio.
); } return (
Gestión
Estado de unidades — {building.name}
Tocá cada chip para cambiar el estado y guardá los cambios al final.
{counts.disponible} disponibles {counts.reservado} reservadas {counts.vendido} vendidas {dirty && (sin guardar)} {work.length} unidades{totalPages > 1 ? ` · pág. ${page + 1} de ${totalPages}` : ''}
Unidad
Estado por piso
{pageRows.map((row, ri) => { const globalRi = pageOffset + ri; return (
e.currentTarget.style.background = 'rgba(255,255,255,0.025)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{row.name}
{row.type}
{row.floors.map((f, fi) => ( onCycle(globalRi, fi)} /> ))}
); })}
{window.CL_STATUS_CYCLE.map(k => { const s = window.CL_STATUS[k]; return (
{s.label}
); })}
Cambio sin guardar
Click en un chip para rotar el estado
); }; // ---- PDF EXPORT ---- function printMetricas(building, pLabel, stats, bars) { const date = new Date().toLocaleDateString('es-AR', { day: '2-digit', month: 'long', year: 'numeric' }); const barMax = Math.max(1, ...bars.map(b => b.value)); const barW = Math.max(2, Math.floor(480 / bars.length) - 2); const barsHtml = bars.map(b => { const h = Math.round((b.value / barMax) * 80); return `
${b.value || ''}
${b.label}
`; }).join(''); const topRows = (stats.topUnidades || []).map((s, i) => `#${i+1}${s.name}${s.views.toLocaleString('es-AR')}` ).join(''); const html = ` Métricas ${building.name} — ${pLabel}
Prisma Studios · Portal de Clientes

Métricas — ${building.name}

Período: ${pLabel}  ·  Generado el ${date}

Visitas totales
${stats.visitas}
Sesiones únicas
${stats.sesiones}
Tasa de consultas
${stats.tasa}
Unidad más vista
${stats.topUnit}
Sección más vista
${stats.topSection}
360° más visto
${stats.top360}
Piso más visto
${stats.topFloor}
Actividad por período
${barsHtml}
Conversiones
AcciónCantidad
Consultas WhatsApp${stats.conversions.whatsapp}
Descargas PDF${stats.conversions.pdf}
Recorridos 360°${stats.conversions.tour}
${topRows ? `
Unidades más vistas
${topRows}
#UnidadVistas
` : ''} `; const w = window.open('', '_blank', 'width=860,height=720'); w.document.write(html); w.document.close(); w.focus(); setTimeout(() => w.print(), 500); } // ---- MÉTRICAS ---- const ClScreenMetricas = ({ building, period, setPeriod }) => { const [open, setOpen] = useSS(false); const [events, setEvents] = useSS([]); const [loading, setLoading] = useSS(true); const ref = useSR(null); useSE(() => { const fn = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', fn); return () => document.removeEventListener('mousedown', fn); }, []); useSE(() => { let active = true; setLoading(true); window._sb .from('eventos') .select('tipo, valor, session_id, created_at') .eq('edificio_id', building.id) .gte('created_at', window.clRangeStart(period)) .order('created_at', { ascending: false }) .limit(5000) .then(({ data }) => { if (active) { setEvents(data || []); setLoading(false); } }); return () => { active = false; }; }, [building.id, period]); const pLabel = window.CL_PERIODS.find(p => p.id === period).label; const ac = window.acHex(building.accent); const stats = useSM(() => { const visitas = events.filter(e => e.tipo === 'vista_seccion').length; const sesiones = new Set(events.map(e => e.session_id)).size; const unitCounts = events .filter(e => e.tipo === 'click_unidad' && e.valor) .reduce((a, e) => { a[e.valor] = (a[e.valor] || 0) + 1; return a; }, {}); const topUnit = Object.entries(unitCounts).sort((a, b) => b[1] - a[1])[0]; const secCounts = events .filter(e => e.tipo === 'vista_seccion' && e.valor) .reduce((a, e) => { a[e.valor] = (a[e.valor] || 0) + 1; return a; }, {}); const topSec = Object.entries(secCounts).sort((a, b) => b[1] - a[1])[0]; const tourCounts = events .filter(e => e.tipo === 'click_360' && e.valor) .reduce((a, e) => { a[e.valor] = (a[e.valor] || 0) + 1; return a; }, {}); const top360 = Object.entries(tourCounts).sort((a, b) => b[1] - a[1])[0]; const pisoCounts = events .filter(e => e.tipo === 'click_piso' && e.valor) .reduce((a, e) => { a[e.valor] = (a[e.valor] || 0) + 1; return a; }, {}); const topFloor = Object.entries(pisoCounts).sort((a, b) => b[1] - a[1])[0]; const waCount = events.filter(e => e.tipo === 'click_whatsapp').length; const pdfCount = events.filter(e => e.tipo === 'click_pdf').length; const tourCount= events.filter(e => e.tipo === 'click_360').length; const tasa = visitas > 0 ? ((waCount + pdfCount) / visitas * 100).toFixed(1) + '%' : '—'; // top unidades por vistas const topUnidades = Object.entries(unitCounts) .sort((a, b) => b[1] - a[1]).slice(0, 5) .map(([name, views]) => ({ name, views })); const SEC_LABEL = { unidades: 'Unidades', pisos: 'Pisos', ubicacion: 'Ubicación', edificio: 'Edificio', recorrido360: 'Recorrido 360°', hero: 'Inicio', }; const capSec = (v) => SEC_LABEL[v] || (v ? v.charAt(0).toUpperCase() + v.slice(1) : '—'); return { visitas, sesiones: sesiones || visitas, topUnit: topUnit ? topUnit[0] : '—', topSection: topSec ? capSec(topSec[0]) : '—', top360: top360 ? top360[0] : '—', topFloor: topFloor ? topFloor[0] : '—', tasa, conversions: { whatsapp: waCount, pdf: pdfCount, tour: tourCount }, topUnidades, }; }, [events]); // gráfico de barras const bars = useSM(() => { if (period === 'hoy') { const byHour = {}; events.forEach(e => { const h = new Date(e.created_at).getHours(); byHour[h] = (byHour[h] || 0) + 1; }); return Array.from({ length: 24 }, (_, h) => ({ label: h % 4 === 0 ? String(h).padStart(2, '0') : '', value: byHour[h] || 0, })); } const p = window.CL_PERIODS.find(x => x.id === period); const n = p ? p.days : 7; const pts = Math.min(n, 30); const byDay = {}; events.forEach(e => { const d = e.created_at.slice(0, 10); byDay[d] = (byDay[d] || 0) + 1; }); return Array.from({ length: pts }, (_, i) => { const d = new Date(Date.now() - (pts - 1 - i) * 86400000); const key = d.toISOString().slice(0, 10); return { label: d.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }), value: byDay[key] || 0, }; }); }, [events, period]); const everyLabel = period === 'hoy' ? 1 : bars.length > 14 ? 3 : 1; const maxViews = stats.topUnidades[0]?.views || 1; return (
Análisis
Métricas del showroom
Cómo interactúan los compradores con {building.name}.
{!loading && ( )}
{open && (
{window.CL_PERIODS.map(p => { const active = p.id === period; return ( ); })}
)}
{loading ? (
Cargando métricas…
) : ( <> {/* fila 1: visitas, sesiones, tasa */}
{/* fila 2: top unit/section/360/piso */}
{/* gráfico + conversiones */}
Visitas {period === 'hoy' ? 'por hora' : 'por día'}
{period === 'hoy' ? 'Distribución horaria · hoy' : pLabel + ' · ' + bars.length + ' registros'}
Conversiones
Acciones de interés · {pLabel.toLowerCase()}
{/* top unidades */} {stats.topUnidades.length > 0 && (
Unidades más vistas
Por número de clicks · {pLabel.toLowerCase()}
{stats.topUnidades.map((s, i) => (
#{i + 1}
{s.name} {s.views.toLocaleString('es-AR')}
))}
)} )}
); }; const ClConvRow = ({ icon: Icon, accent, label, value, border }) => { const c = window.acHex(accent); return (
{label} {value}
); }; Object.assign(window, { ClScreenLogin, ClScreenDashboard, ClScreenDisponibilidad, ClScreenMetricas });