// ============ 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 (
);
};
// ---- 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 (
);
};
const ClMiniStat = ({ icon: Icon, value, label, accent }) => {
const c = window.acHex(accent);
return (
);
};
// ---- 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}` : ''}
{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.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}
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ón | Cantidad |
| Consultas WhatsApp | ${stats.conversions.whatsapp} |
| Descargas PDF | ${stats.conversions.pdf} |
| Recorridos 360° | ${stats.conversions.tour} |
${topRows ? `Unidades más vistas
` : ''}
`;
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 });